Merge pull request #9635 from opf/fix/hal-error-thrown

Throw an actual error object in the HalResource service
pull/9637/head
Oliver Günther 3 years ago committed by GitHub
commit 050aa60bc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 35
      frontend/package-lock.json
  2. 5
      frontend/package.json
  3. 5
      frontend/src/app/app.module.ts
  4. 2
      frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-packages-paths.ts
  5. 39
      frontend/src/app/core/errors/sentry/sentry-reporter.ts
  6. 46
      frontend/src/app/features/hal/services/hal-aware-error-handler.ts
  7. 22
      frontend/src/app/features/hal/services/hal-error.ts
  8. 13
      frontend/src/app/features/hal/services/hal-resource-notification.service.ts
  9. 74
      frontend/src/app/features/hal/services/hal-resource.service.ts
  10. 13
      frontend/src/app/features/work-packages/components/work-package-comment/work-package-comment.component.ts
  11. 5
      frontend/src/app/features/work-packages/components/wp-new/wp-create.component.ts
  12. 11
      frontend/src/app/shared/components/fields/edit/edit-form/edit-form.ts
  13. 2
      frontend/src/app/shared/components/work-package-graphs/configuration/wp-graph-configuration.service.ts

@ -2795,6 +2795,11 @@
"tslib": "^1.9.3"
},
"dependencies": {
"@sentry/types": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.3.tgz",
"integrity": "sha512-BpA+9FherWgYlkMD/82bGFh/gAqZNlZX5UE8vWLKyyzNyOEEz3v9ScxE8dOSWE4v5iXJR1O3jjxaTcRQxPVgCA=="
},
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@ -2813,6 +2818,11 @@
"tslib": "^1.9.3"
},
"dependencies": {
"@sentry/types": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.3.tgz",
"integrity": "sha512-BpA+9FherWgYlkMD/82bGFh/gAqZNlZX5UE8vWLKyyzNyOEEz3v9ScxE8dOSWE4v5iXJR1O3jjxaTcRQxPVgCA=="
},
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@ -2832,6 +2842,11 @@
"tslib": "^1.9.3"
},
"dependencies": {
"@sentry/types": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.3.tgz",
"integrity": "sha512-BpA+9FherWgYlkMD/82bGFh/gAqZNlZX5UE8vWLKyyzNyOEEz3v9ScxE8dOSWE4v5iXJR1O3jjxaTcRQxPVgCA=="
},
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@ -2849,6 +2864,11 @@
"tslib": "^1.9.3"
},
"dependencies": {
"@sentry/types": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.3.tgz",
"integrity": "sha512-BpA+9FherWgYlkMD/82bGFh/gAqZNlZX5UE8vWLKyyzNyOEEz3v9ScxE8dOSWE4v5iXJR1O3jjxaTcRQxPVgCA=="
},
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@ -2866,6 +2886,11 @@
"tslib": "^1.9.3"
},
"dependencies": {
"@sentry/types": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.3.tgz",
"integrity": "sha512-BpA+9FherWgYlkMD/82bGFh/gAqZNlZX5UE8vWLKyyzNyOEEz3v9ScxE8dOSWE4v5iXJR1O3jjxaTcRQxPVgCA=="
},
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@ -2885,6 +2910,11 @@
"tslib": "^1.9.3"
},
"dependencies": {
"@sentry/types": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.3.tgz",
"integrity": "sha512-BpA+9FherWgYlkMD/82bGFh/gAqZNlZX5UE8vWLKyyzNyOEEz3v9ScxE8dOSWE4v5iXJR1O3jjxaTcRQxPVgCA=="
},
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@ -2906,6 +2936,11 @@
"tslib": "^1.9.3"
},
"dependencies": {
"@sentry/types": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.2.3.tgz",
"integrity": "sha512-BpA+9FherWgYlkMD/82bGFh/gAqZNlZX5UE8vWLKyyzNyOEEz3v9ScxE8dOSWE4v5iXJR1O3jjxaTcRQxPVgCA=="
},
"tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",

@ -80,8 +80,9 @@
"@ng-select/ng-option-highlight": "0.0.5",
"@ng-select/ng-select": "^4.0.4",
"@ngx-formly/core": "^5.10.19",
"@sentry/angular": "^6.2.3",
"@sentry/tracing": "^6.2.3",
"@sentry/angular": "6.2.3",
"@sentry/tracing": "6.2.3",
"@sentry/types": "^6.2.3",
"@uirouter/angular": "^8.0.0",
"@uirouter/core": "^6.0.7",
"@uirouter/rx": "^0.6.5",

@ -27,7 +27,10 @@
//++
import {
APP_INITIALIZER, ApplicationRef, Injector, NgModule,
APP_INITIALIZER,
ApplicationRef,
Injector,
NgModule,
} from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { OpContextMenuTrigger } from 'core-app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive';

@ -157,7 +157,7 @@ export class APIV3WorkPackagesPaths extends CachableAPIV3Collection<WorkPackageR
protected loadCollectionsFor(ids:string[]):Promise<WorkPackageCollectionResource[]> {
return this
.halResourceService
.getAllPaginated<WorkPackageCollectionResource[]>(
.getAllPaginated<WorkPackageCollectionResource>(
this.path,
ids.length,
{

@ -27,9 +27,15 @@
//++
import {
Event as SentryEvent, Hub, Scope, Severity,
Event as SentryEvent,
Hub,
Scope,
Severity,
} from '@sentry/types';
import { environment } from '../../../../environments/environment';
import { EventHint } from '@sentry/angular';
import { HttpErrorResponse } from '@angular/common/http';
import { debugLog } from 'core-app/shared/helpers/debug_output';
export type ScopeCallback = (scope:Scope) => void;
export type MessageSeverity = 'fatal'|'error'|'warning'|'log'|'info'|'debug';
@ -55,7 +61,7 @@ export interface ErrorReporter extends CaptureInterface {
interface QueuedMessage {
type:'captureMessage'|'captureException';
args:any[];
args:unknown[];
}
export class SentryReporter implements ErrorReporter {
@ -82,7 +88,7 @@ export class SentryReporter implements ErrorReporter {
const version = sentryElement.dataset.version || 'unknown';
const traceFactor = parseFloat(sentryElement.dataset.tracingFactor || '0.0');
import('./sentry-dependency').then((imported) => {
void import('./sentry-dependency').then((imported) => {
const sentry = imported.Sentry;
sentry.init({
dsn,
@ -114,10 +120,10 @@ export class SentryReporter implements ErrorReporter {
'Non-Error exception captured with keys: $embedded, $halType, $links, $loaded',
],
beforeSend: (event) => this.filterEvent(event),
beforeSend: (event, hint) => SentryReporter.filterEvent(event, hint),
});
this.sentryLoaded(sentry as any);
this.sentryLoaded(sentry as unknown as Hub);
});
}
@ -127,13 +133,15 @@ export class SentryReporter implements ErrorReporter {
// Send all messages from before sentry got loaded
this.messageStack.forEach((item) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call,@typescript-eslint/no-unsafe-member-access
this[item.type].bind(this).apply(item.args);
});
}
public captureMessage(msg:string, severity:MessageSeverity = 'info'):void {
if (!this.client) {
return this.handleOfflineMessage('captureMessage', [msg, severity]);
this.handleOfflineMessage('captureMessage', [msg, severity]);
return;
}
this.client.withScope((scope:Scope) => {
@ -145,11 +153,12 @@ export class SentryReporter implements ErrorReporter {
public captureException(err:Error|string):void {
if (!this.client || !err) {
this.handleOfflineMessage('captureException', [err]);
throw err;
throw (err as Error);
}
if (typeof err === 'string') {
return this.captureMessage(err, 'error');
this.captureMessage(err, 'error');
return;
}
this.client.withScope((scope:Scope) => {
@ -172,11 +181,11 @@ export class SentryReporter implements ErrorReporter {
* @param type
* @param args
*/
private handleOfflineMessage(type:'captureMessage'|'captureException', args:any[]) {
private handleOfflineMessage(type:'captureMessage'|'captureException', args:unknown[]) {
if (this.sentryConfigured) {
this.messageStack.push({ type, args });
} else {
console.log('[ErrorReporter] Would queue sentry message %O %O, but is not configured.', type, args);
debugLog('[ErrorReporter] Would queue sentry message %O %O, but is not configured.', type, args);
}
}
@ -199,8 +208,16 @@ export class SentryReporter implements ErrorReporter {
* it from being sent.
*
* @param event
* @param hint
*/
private filterEvent(event:SentryEvent):SentryEvent|null {
private static filterEvent(event:SentryEvent, hint:EventHint|undefined):SentryEvent|null {
// avoid duplicate requests on thrown angular errors, they
// are handled by the hal error handler
// https://github.com/getsentry/sentry-javascript/issues/2532#issuecomment-875428325
if (hint?.originalException instanceof HttpErrorResponse) {
return null;
}
const unsupportedBrowser = document.body.classList.contains('-unsupported-browser');
if (unsupportedBrowser) {
console.warn('Browser is not supported, skipping sentry reporting completely.');

@ -1,7 +1,16 @@
import { ErrorHandler, Injectable } from '@angular/core';
import {
ErrorHandler,
Injectable,
} from '@angular/core';
import { I18nService } from 'core-app/core/i18n/i18n.service';
import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { ErrorResource } from 'core-app/features/hal/resources/error-resource';
import { HalError } from 'core-app/features/hal/services/hal-error';
import { HttpErrorResponse } from '@angular/common/http';
interface RejectedPromise {
rejection:unknown;
}
@Injectable()
export class HalAwareErrorHandler extends ErrorHandler {
@ -13,17 +22,31 @@ export class HalAwareErrorHandler extends ErrorHandler {
super();
}
public handleError(error:unknown) {
public handleError(error:unknown):void {
let message:string = this.text.internal_error;
if (error instanceof ErrorResource) {
// Angular wraps our errors into uncaught promises if
// no one catches the error explictly. Unwrap the error in that case
if ((error as RejectedPromise)?.rejection instanceof HalError) {
this.handleError((error as RejectedPromise).rejection);
return;
}
if (error instanceof HalError) {
console.error('Returned HTTP HAL error resource %O', error.message);
message = error.httpError?.status >= 500 ? `${message} ${error.message}` : error.message;
HalAwareErrorHandler.captureHttpError(error.httpError);
} else if (error instanceof ErrorResource) {
console.error('Returned error resource %O', error);
message += ` ${error.errorMessages.join('\n')}`;
} else if (error instanceof HalResource) {
console.error('Returned hal resource %O', error);
message += `Resource returned ${error.name}`;
} else if (error instanceof Error) {
window.ErrorReporter.captureException(error);
HalAwareErrorHandler.reportError(error);
} else if (error instanceof HttpErrorResponse) {
HalAwareErrorHandler.captureHttpError(error);
message = error.message;
} else if (typeof error === 'string') {
window.ErrorReporter.captureMessage(error);
message = error;
@ -31,4 +54,19 @@ export class HalAwareErrorHandler extends ErrorHandler {
super.handleError(message);
}
private static reportError(error:Error):void {
window.ErrorReporter.captureException(error);
}
/**
* Report any 5xx errors to sentry, if configured.
* @param httpError
* @private
*/
private static captureHttpError(httpError:HttpErrorResponse):void {
if (httpError.status >= 500) {
HalAwareErrorHandler.reportError(httpError);
}
}
}

@ -0,0 +1,22 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorResource } from 'core-app/features/hal/resources/error-resource';
export class HalError extends Error {
readonly name = 'HALError';
get message():string {
return this.resource.message || this.httpError?.message || 'Unknown error';
}
get errorIdentifier():string {
return this.resource.errorIdentifier;
}
constructor(
readonly httpError:HttpErrorResponse,
readonly resource:ErrorResource,
) {
super();
Object.setPrototypeOf(this, HalError.prototype);
}
}

@ -37,6 +37,7 @@ import { HalResource } from 'core-app/features/hal/resources/hal-resource';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service';
import { ErrorResource } from 'core-app/features/hal/resources/error-resource';
import { HalError } from 'core-app/features/hal/services/hal-error';
@Injectable()
export class HalResourceNotificationService {
@ -78,8 +79,8 @@ export class HalResourceNotificationService {
// Some transformation may already have returned the error as a HAL resource,
// which we will forward to handleErrorResponse
if (response instanceof ErrorResource) {
return this.handleErrorResponse(response, resource);
if (response instanceof HalError) {
return this.handleErrorResponse(response.resource, resource);
}
const errorBody = this.retrieveError(response);
@ -108,7 +109,7 @@ export class HalResourceNotificationService {
public retrieveErrorMessage(response:unknown):string {
const error = this.retrieveError(response);
if (error instanceof ErrorResource) {
if (error instanceof ErrorResource || error instanceof HalError) {
return error.message;
}
@ -142,6 +143,10 @@ export class HalResourceNotificationService {
}
protected handleErrorResponse(errorResource:any, resource?:HalResource) {
if (errorResource instanceof HalError && resource) {
return this.showError(errorResource.resource, resource);
}
if (!(errorResource instanceof ErrorResource)) {
return this.showGeneralError(errorResource);
}
@ -150,7 +155,7 @@ export class HalResourceNotificationService {
return this.showError(errorResource, resource);
}
this.showApiErrorMessages(errorResource);
return this.showApiErrorMessages(errorResource);
}
public showError(errorResource:any, resource:HalResource) {

@ -26,10 +26,23 @@
// See docs/COPYRIGHT.rdoc for more details.
//++
import { Injectable, Injector } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { catchError, map } from 'rxjs/operators';
import { Observable, throwError } from 'rxjs';
import {
Injectable,
Injector,
} from '@angular/core';
import {
HttpClient,
HttpErrorResponse,
HttpParams,
} from '@angular/common/http';
import {
catchError,
map,
} from 'rxjs/operators';
import {
Observable,
throwError,
} from 'rxjs';
import { CollectionResource } from 'core-app/features/hal/resources/collection-resource';
import { ErrorResource } from 'core-app/features/hal/resources/error-resource';
import * as Pako from 'pako';
@ -41,10 +54,17 @@ import {
HTTPClientParamMap,
HTTPSupportedMethods,
} from 'core-app/features/hal/http/http.interfaces';
import { HalLink, HalLinkInterface } from 'core-app/features/hal/hal-link/hal-link';
import {
HalLink,
HalLinkInterface,
} from 'core-app/features/hal/hal-link/hal-link';
import { URLParamsEncoder } from 'core-app/features/hal/services/url-params-encoder';
import { HalResource, HalResourceClass } from 'core-app/features/hal/resources/hal-resource';
import {
HalResource,
HalResourceClass,
} from 'core-app/features/hal/resources/hal-resource';
import { initializeHalProperties } from '../helpers/hal-resource-builder';
import { HalError } from 'core-app/features/hal/services/hal-error';
export interface HalResourceFactoryConfigInterface {
cls?:any;
@ -58,18 +78,20 @@ export class HalResourceService {
*/
private config:{ [typeName:string]:HalResourceFactoryConfigInterface } = {};
constructor(readonly injector:Injector,
readonly http:HttpClient) {
constructor(
readonly injector:Injector,
readonly http:HttpClient,
) {
}
/**
* Perform a HTTP request and return a HalResource promise.
*/
public request<T extends HalResource>(method:HTTPSupportedMethods, href:string, data?:any, headers:HTTPClientHeaders = {}):Observable<T> {
public request<T extends HalResource>(method:HTTPSupportedMethods, href:string, data?:unknown, headers:HTTPClientHeaders = {}):Observable<T> {
// HttpClient requires us to create HttpParams instead of passing data for get
// so forward to that method instead.
if (method === 'get') {
return this.get(href, data, headers);
return this.get(href, data as HTTPClientParamMap|undefined, headers);
}
const config:HTTPClientOptions = {
@ -79,20 +101,19 @@ export class HalResourceService {
responseType: 'json',
};
return this._request(method, href, config);
return this.performRequest(method, href, config);
}
private _request<T>(method:HTTPSupportedMethods, href:string, config:HTTPClientOptions):Observable<T> {
return this.http.request<T>(method, href, config)
private performRequest<T extends HalResource>(method:HTTPSupportedMethods, href:string, config:HTTPClientOptions):Observable<T> {
return this.http.request(method, href, config)
.pipe(
map((response:any) => this.createHalResource(response)),
map((response:unknown) => this.createHalResource<T>(response)),
catchError((error:HttpErrorResponse) => {
whenDebugging(() => console.error(`Failed to ${method} ${href}: ${error.name}`));
const resource = this.createHalResource<ErrorResource>(error.error);
resource.httpError = error;
return throwError(resource);
return throwError(new HalError(error, resource));
}),
) as any;
);
}
/**
@ -111,7 +132,7 @@ export class HalResourceService {
responseType: 'json',
};
return this._request('get', href, config);
return this.performRequest('get', href, config);
}
/**
@ -124,20 +145,27 @@ export class HalResourceService {
* @param headers
* @return {Promise<CollectionResource[]>}
*/
public async getAllPaginated<T extends HalResource[]>(href:string, expected:number, params:any = {}, headers:HTTPClientHeaders = {}) {
public async getAllPaginated<T extends CollectionResource>(
href:string,
expected:number,
params:Record<string, string|number> = {},
headers:HTTPClientHeaders = {},
):Promise<T[]> {
// Total number retrieved
let retrieved = 0;
// Current offset page
let page = 1;
// Accumulated results
const allResults:T = [] as any;
const allResults:T[] = [];
// If possible, request all at once.
params.pageSize = expected;
const requestParams = { ...params };
requestParams.pageSize = expected;
while (retrieved < expected) {
params.offset = page;
requestParams.offset = page;
const promise = this.request('get', href, this.toEprops(params), headers).toPromise();
const promise = this.request<T>('get', href, this.toEprops(requestParams), headers).toPromise();
// eslint-disable-next-line no-await-in-loop
const results = await promise;
if (results.count === 0) {

@ -50,6 +50,7 @@ import { CommentService } from 'core-app/features/work-packages/components/wp-ac
import { WorkPackagesActivityService } from 'core-app/features/work-packages/components/wp-single-view-tabs/activity-panel/wp-activity.service';
import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
import { ErrorResource } from 'core-app/features/hal/resources/error-resource';
import { HalError } from 'core-app/features/hal/services/hal-error';
@Component({
selector: 'work-package-comment',
@ -146,13 +147,13 @@ export class WorkPackageCommentComponent extends WorkPackageCommentFieldHandler
this.inFlight = true;
await this.onSubmit();
const indicator = this.loadingIndicator.wpDetails;
return indicator.promise = this.commentService.createComment(this.workPackage, this.commentValue)
indicator.promise = this.commentService.createComment(this.workPackage, this.commentValue)
.then(() => {
this.active = false;
this.NotificationsService.addSuccess(this.I18n.t('js.work_packages.comment_added'));
this.wpLinkedActivities.require(this.workPackage, true);
this
void this.wpLinkedActivities.require(this.workPackage, true);
void this
.apiV3Service
.work_packages
.id(this.workPackage.id!)
@ -163,12 +164,14 @@ export class WorkPackageCommentComponent extends WorkPackageCommentFieldHandler
})
.catch((error:any) => {
this.inFlight = false;
if (error instanceof ErrorResource) {
this.workPackageNotificationService.showError(error, this.workPackage);
if (error instanceof HalError) {
this.workPackageNotificationService.showError(error.resource, this.workPackage);
} else {
this.NotificationsService.addError(this.I18n.t('js.work_packages.comment_send_failed'));
}
});
return indicator.promise;
}
scrollToBottom():void {

@ -48,6 +48,7 @@ import { APIV3Service } from 'core-app/core/apiv3/api-v3.service';
import { HalSource } from 'core-app/features/hal/resources/hal-resource';
import { OpTitleService } from 'core-app/core/html/op-title.service';
import { WorkPackageCreateService } from './wp-create.service';
import { HalError } from 'core-app/features/hal/services/hal-error';
@Directive()
export class WorkPackageCreateComponent extends UntilDestroyedMixin implements OnInit {
@ -152,8 +153,8 @@ export class WorkPackageCreateComponent extends UntilDestroyedMixin implements O
});
}
})
.catch((error:any) => {
if (error.errorIdentifier === 'urn:openproject-org:api:v3:errors:MissingPermission') {
.catch((error:unknown) => {
if (error instanceof HalError && error.errorIdentifier === 'urn:openproject-org:api:v3:errors:MissingPermission') {
this.apiV3Service.root.get().subscribe((root:RootResource) => {
if (!root.user) {
// Not logged in

@ -41,6 +41,7 @@ import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decora
import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service';
import { ErrorResource } from 'core-app/features/hal/resources/error-resource';
import isNewResource from 'core-app/features/hal/helpers/is-new-resource';
import { HalError } from 'core-app/features/hal/services/hal-error';
export const activeFieldContainerClassName = 'inline-edit--active-field';
export const activeFieldClassName = 'inline-edit--field';
@ -193,8 +194,8 @@ export abstract class EditForm<T extends HalResource = HalResource> {
.catch((error:ErrorResource|unknown) => {
this.halNotification.handleRawError(error, this.resource);
if (error instanceof ErrorResource) {
this.handleSubmissionErrors(error);
if (error instanceof HalError) {
this.handleSubmissionErrors(error.resource);
reject();
}
@ -226,12 +227,12 @@ export abstract class EditForm<T extends HalResource = HalResource> {
});
}
protected handleSubmissionErrors(error:any) {
protected handleSubmissionErrors(error:ErrorResource):void {
// Process single API errors
this.handleErroneousAttributes(error);
}
protected handleErroneousAttributes(error:any) {
protected handleErroneousAttributes(error:ErrorResource):void {
// Get attributes withe errors
const erroneousAttributes = error.getInvolvedAttributes();
@ -241,7 +242,7 @@ export abstract class EditForm<T extends HalResource = HalResource> {
return;
}
return this.setErrorsForFields(erroneousAttributes);
this.setErrorsForFields(erroneousAttributes);
}
private setErrorsForFields(erroneousFields:string[]) {

@ -21,7 +21,7 @@ export class WpGraphConfigurationService {
private _forms:{ [id:string]:QueryFormResource } = {};
private _formsPromise:Promise<void[]>|null;
private _formsPromise:Promise<unknown>|null;
constructor(
readonly I18n:I18nService,

Loading…
Cancel
Save