Browse Source

Feature/reuse notification service for alert dialogs (#3670)

* Reuse notification service for alert dialogs
pull/3673/head
Daniel Idem 8 months ago
committed by GitHub
parent
commit
56ddbaf972
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      apps/client/src/app/app.component.ts
  2. 4
      apps/client/src/app/components/accounts-table/accounts-table.component.ts
  3. 10
      apps/client/src/app/components/admin-jobs/admin-jobs.component.ts
  4. 10
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  5. 14
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  6. 6
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  7. 6
      apps/client/src/app/components/header/header.component.ts
  8. 8
      apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts
  9. 11
      apps/client/src/app/components/user-account-membership/user-account-membership.component.ts
  10. 6
      apps/client/src/app/components/user-account-settings/user-account-settings.component.ts
  11. 6
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  12. 8
      apps/client/src/app/pages/demo/demo-page.component.ts
  13. 11
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  14. 10
      libs/ui/src/lib/activities-table/activities-table.component.ts

6
apps/client/src/app/app.component.ts

@ -28,6 +28,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { filter, takeUntil } from 'rxjs/operators'; import { filter, takeUntil } from 'rxjs/operators';
import { NotificationService } from './core/notification/notification.service';
import { DataService } from './services/data.service'; import { DataService } from './services/data.service';
import { ImpersonationStorageService } from './services/impersonation-storage.service'; import { ImpersonationStorageService } from './services/impersonation-storage.service';
import { TokenStorageService } from './services/token-storage.service'; import { TokenStorageService } from './services/token-storage.service';
@ -81,6 +82,7 @@ export class AppComponent implements OnDestroy, OnInit {
private dialog: MatDialog, private dialog: MatDialog,
@Inject(DOCUMENT) private document: Document, @Inject(DOCUMENT) private document: Document,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private notificationService: NotificationService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private title: Title, private title: Title,
@ -199,7 +201,9 @@ export class AppComponent implements OnDestroy, OnInit {
if (this.user.systemMessage.routerLink) { if (this.user.systemMessage.routerLink) {
this.router.navigate(this.user.systemMessage.routerLink); this.router.navigate(this.user.systemMessage.routerLink);
} else { } else {
alert(this.user.systemMessage.message); this.notificationService.alert({
title: this.user.systemMessage.message
});
} }
} }

4
apps/client/src/app/components/accounts-table/accounts-table.component.ts

@ -120,7 +120,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
} }
public onOpenComment(aComment: string) { public onOpenComment(aComment: string) {
alert(aComment); this.notificationService.alert({
title: aComment
});
} }
public onTransferBalance() { public onTransferBalance() {

10
apps/client/src/app/components/admin-jobs/admin-jobs.component.ts

@ -1,3 +1,4 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
@ -59,6 +60,7 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService,
private userService: UserService private userService: UserService
) { ) {
this.userService.stateChanged this.userService.stateChanged
@ -119,11 +121,15 @@ export class AdminJobsComponent implements OnDestroy, OnInit {
} }
public onViewData(aData: AdminJobs['jobs'][0]['data']) { public onViewData(aData: AdminJobs['jobs'][0]['data']) {
alert(JSON.stringify(aData, null, ' ')); this.notificationService.alert({
title: JSON.stringify(aData, null, ' ')
});
} }
public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) { public onViewStacktrace(aStacktrace: AdminJobs['jobs'][0]['stacktrace']) {
alert(JSON.stringify(aStacktrace, null, ' ')); this.notificationService.alert({
title: JSON.stringify(aStacktrace, null, ' ')
});
} }
public ngOnDestroy() { public ngOnDestroy() {

10
apps/client/src/app/components/admin-market-data/admin-market-data.service.ts

@ -1,3 +1,4 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config'; import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
@ -11,7 +12,10 @@ import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs';
@Injectable() @Injectable()
export class AdminMarketDataService { export class AdminMarketDataService {
public constructor(private adminService: AdminService) {} public constructor(
private adminService: AdminService,
private notificationService: NotificationService
) {}
public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
const confirmation = confirm( const confirmation = confirm(
@ -46,7 +50,9 @@ export class AdminMarketDataService {
forkJoin(deleteRequests) forkJoin(deleteRequests)
.pipe( .pipe(
catchError(() => { catchError(() => {
alert($localize`Oops! Could not delete profiles.`); this.notificationService.alert({
title: $localize`Oops! Could not delete profiles.`
});
return EMPTY; return EMPTY;
}), }),

14
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -1,6 +1,7 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto'; import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service'; import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util'; import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
@ -94,6 +95,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
public dialogRef: MatDialogRef<AssetProfileDialog>, public dialogRef: MatDialogRef<AssetProfileDialog>,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService,
private snackBar: MatSnackBar private snackBar: MatSnackBar
) {} ) {}
@ -329,19 +331,23 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}) })
.pipe( .pipe(
catchError(({ error }) => { catchError(({ error }) => {
alert(`Error: ${error?.message}`); this.notificationService.alert({
message: error?.message,
title: $localize`Error`
});
return EMPTY; return EMPTY;
}), }),
takeUntil(this.unsubscribeSubject) takeUntil(this.unsubscribeSubject)
) )
.subscribe(({ price }) => { .subscribe(({ price }) => {
alert( this.notificationService.alert({
$localize`The current market price is` + title:
$localize`The current market price is` +
' ' + ' ' +
price + price +
' ' + ' ' +
this.assetProfileForm.get('currency').value this.assetProfileForm.get('currency').value
); });
}); });
} }

6
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -1,3 +1,4 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
@ -60,6 +61,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
private cacheService: CacheService, private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private notificationService: NotificationService,
private userService: UserService private userService: UserService
) { ) {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
@ -126,7 +128,9 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
const currencies = uniq([...this.customCurrencies, currency]); const currencies = uniq([...this.customCurrencies, currency]);
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies }); this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
} else { } else {
alert($localize`${currency} is an invalid currency!`); this.notificationService.alert({
title: $localize`${currency} is an invalid currency!`
});
} }
} }
} }

6
apps/client/src/app/components/header/header.component.ts

@ -1,6 +1,7 @@
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component'; import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
import { LayoutService } from '@ghostfolio/client/core/layout.service'; import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { import {
@ -93,6 +94,7 @@ export class HeaderComponent implements OnChanges {
private dialog: MatDialog, private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private layoutService: LayoutService, private layoutService: LayoutService,
private notificationService: NotificationService,
private router: Router, private router: Router,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService,
@ -240,7 +242,9 @@ export class HeaderComponent implements OnChanges {
.loginAnonymous(data?.accessToken) .loginAnonymous(data?.accessToken)
.pipe( .pipe(
catchError(() => { catchError(() => {
alert($localize`Oops! Incorrect Security Token.`); this.notificationService.alert({
title: $localize`Oops! Incorrect Security Token.`
});
return EMPTY; return EMPTY;
}), }),

8
apps/client/src/app/components/user-account-access/create-or-update-access-dialog/create-or-update-access-dialog.component.ts

@ -1,4 +1,5 @@
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto'; import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { validateObjectForForm } from '@ghostfolio/client/util/form.util'; import { validateObjectForForm } from '@ghostfolio/client/util/form.util';
@ -33,7 +34,8 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams, @Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>, public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
private dataService: DataService, private dataService: DataService,
private formBuilder: FormBuilder private formBuilder: FormBuilder,
private notificationService: NotificationService
) {} ) {}
ngOnInit() { ngOnInit() {
@ -85,7 +87,9 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
.pipe( .pipe(
catchError((error) => { catchError((error) => {
if (error.status === StatusCodes.BAD_REQUEST) { if (error.status === StatusCodes.BAD_REQUEST) {
alert($localize`Oops! Could not grant access.`); this.notificationService.alert({
title: $localize`Oops! Could not grant access.`
});
} }
return EMPTY; return EMPTY;

11
apps/client/src/app/components/user-account-membership/user-account-membership.component.ts

@ -1,3 +1,4 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
@ -46,6 +47,7 @@ export class UserAccountMembershipComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private notificationService: NotificationService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
private stripeService: StripeService, private stripeService: StripeService,
private userService: UserService private userService: UserService
@ -96,13 +98,18 @@ export class UserAccountMembershipComponent implements OnDestroy, OnInit {
return this.stripeService.redirectToCheckout({ sessionId }); return this.stripeService.redirectToCheckout({ sessionId });
}), }),
catchError((error) => { catchError((error) => {
alert(error.message); this.notificationService.alert({
title: error.message
});
throw error; throw error;
}) })
) )
.subscribe((result) => { .subscribe((result) => {
if (result.error) { if (result.error) {
alert(result.error.message); this.notificationService.alert({
title: result.error.message
});
} }
}); });
} }

6
apps/client/src/app/components/user-account-settings/user-account-settings.component.ts

@ -1,3 +1,4 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { import {
KEY_STAY_SIGNED_IN, KEY_STAY_SIGNED_IN,
@ -69,6 +70,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private notificationService: NotificationService,
private settingsStorageService: SettingsStorageService, private settingsStorageService: SettingsStorageService,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
private tokenStorageService: TokenStorageService, private tokenStorageService: TokenStorageService,
@ -155,7 +157,9 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
}) })
.pipe( .pipe(
catchError(() => { catchError(() => {
alert($localize`Oops! Incorrect Security Token.`); this.notificationService.alert({
title: $localize`Oops! Incorrect Security Token.`
});
return EMPTY; return EMPTY;
}), }),

6
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -3,6 +3,7 @@ import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component'; import { AccountDetailDialog } from '@ghostfolio/client/components/account-detail-dialog/account-detail-dialog.component';
import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces'; import { AccountDetailDialogParams } from '@ghostfolio/client/components/account-detail-dialog/interfaces/interfaces';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -46,6 +47,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private notificationService: NotificationService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router,
private userService: UserService private userService: UserService
@ -305,7 +307,9 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
}) })
.pipe( .pipe(
catchError(() => { catchError(() => {
alert($localize`Oops, cash balance transfer has failed.`); this.notificationService.alert({
title: $localize`Oops, cash balance transfer has failed.`
});
return EMPTY; return EMPTY;
}), }),

8
apps/client/src/app/pages/demo/demo-page.component.ts

@ -1,3 +1,4 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
@ -19,6 +20,7 @@ export class GfDemoPageComponent implements OnDestroy {
public constructor( public constructor(
private dataService: DataService, private dataService: DataService,
private notificationService: NotificationService,
private router: Router, private router: Router,
private tokenStorageService: TokenStorageService private tokenStorageService: TokenStorageService
) { ) {
@ -29,9 +31,9 @@ export class GfDemoPageComponent implements OnDestroy {
const hasToken = this.tokenStorageService.getToken()?.length > 0; const hasToken = this.tokenStorageService.getToken()?.length > 0;
if (hasToken) { if (hasToken) {
alert( this.notificationService.alert({
$localize`As you are already logged in, you cannot access the demo account.` title: $localize`As you are already logged in, you cannot access the demo account.`
); });
} else { } else {
this.tokenStorageService.saveToken(this.info.demoAuthToken, true); this.tokenStorageService.saveToken(this.info.demoAuthToken, true);
} }

11
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -1,3 +1,4 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
@ -41,6 +42,7 @@ export class PricingPageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private notificationService: NotificationService,
private stripeService: StripeService, private stripeService: StripeService,
private userService: UserService private userService: UserService
) {} ) {}
@ -82,13 +84,18 @@ export class PricingPageComponent implements OnDestroy, OnInit {
return this.stripeService.redirectToCheckout({ sessionId }); return this.stripeService.redirectToCheckout({ sessionId });
}), }),
catchError((error) => { catchError((error) => {
alert(error.message); this.notificationService.alert({
title: error.message
});
throw error; throw error;
}) })
) )
.subscribe((result) => { .subscribe((result) => {
if (result.error) { if (result.error) {
alert(result.error.message); this.notificationService.alert({
title: result.error.message
});
} }
}); });
} }

10
libs/ui/src/lib/activities-table/activities-table.component.ts

@ -1,5 +1,6 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component'; import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString, getLocale } from '@ghostfolio/common/helper'; import { getDateFormatString, getLocale } from '@ghostfolio/common/helper';
@ -120,7 +121,10 @@ export class GfActivitiesTableComponent
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor(private router: Router) {} public constructor(
private notificationService: NotificationService,
private router: Router
) {}
public ngOnInit() { public ngOnInit() {
if (this.showCheckbox) { if (this.showCheckbox) {
@ -260,7 +264,9 @@ export class GfActivitiesTableComponent
} }
public onOpenComment(aComment: string) { public onOpenComment(aComment: string) {
alert(aComment); this.notificationService.alert({
title: aComment
});
} }
public onOpenPositionDialog({ dataSource, symbol }: AssetProfileIdentifier) { public onOpenPositionDialog({ dataSource, symbol }: AssetProfileIdentifier) {

Loading…
Cancel
Save