diff --git a/frontend/Exence/src/app/private/transactions-and-categories/create-category-dialog/create-category-dialog.component.html b/frontend/Exence/src/app/private/transactions-and-categories/create-category-dialog/create-category-dialog.component.html index f4086b1..3f867ab 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/create-category-dialog/create-category-dialog.component.html +++ b/frontend/Exence/src/app/private/transactions-and-categories/create-category-dialog/create-category-dialog.component.html @@ -1,9 +1,7 @@ - - -

New category

-
+ +
New category
- +
@let nameControl = form.controls.name; Name @@ -19,7 +17,7 @@

New category

-
+
New category Emoji is required!
} -
- +
@let noteControl = form.controls.note; Note @@ -57,9 +54,9 @@

New category

- +
- +
New category > Create - - +
+
{ private readonly categoryService = inject(CategoryService); @@ -75,9 +77,9 @@ export class CreateCategoryDialogComponent extends DialogComponent - -

New transaction

-
+ +
New transaction
- +
@let titleControl = form.controls.title; Title @@ -18,7 +16,7 @@

New transaction

-
+
@let amountControl = form.controls.amount; Amount @@ -49,7 +47,7 @@

New transaction

-
+
@let categoryControl = form.controls.category; Category @@ -86,12 +84,11 @@

New transaction

- @let recurringControl = form.controls.recurring; Automatically recurring? - + - +
New transaction > Create - - +
+ \ No newline at end of file diff --git a/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.scss b/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.scss index d29c8a7..0b58b75 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.scss +++ b/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.scss @@ -2,4 +2,11 @@ textarea { resize: none; +} + +ex-button { + width: 100%; + @include displaySm { + width: auto; + } } \ No newline at end of file diff --git a/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.ts b/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.ts index 5a9818c..fb6a17f 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.ts +++ b/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.ts @@ -1,6 +1,5 @@ import { Component, inject, OnInit } from '@angular/core'; import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; -import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDatepickerModule } from '@angular/material/datepicker'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -10,13 +9,15 @@ import { Category } from '../../../data-model/modules/category/Category'; import { Transaction } from '../../../data-model/modules/transaction/Transaction'; import { TransactionType } from '../../../data-model/modules/transaction/TransactionType'; import { ButtonComponent } from '../../../shared/button/button.component'; +import { DialogCardComponent } from '../../../shared/dialog-card/dialog-card.component'; import { DialogWithBaseComponent } from '../../../shared/dialog/dialog.service'; import { InputClearButtonComponent } from '../../../shared/input-clear-button/input-clear-button.component'; import { SnackbarService } from '../../../shared/snackbar/snackbar.service'; import { ValidatorComponent } from '../../../shared/validator/validator.component'; import { CategoryService } from '../../category.service'; import { TransactionService } from '../transaction.service'; -import { AutoTrimDirective } from "src/app/shared/auto-trim.directive"; +import { AutoTrimDirective } from '../../../shared/auto-trim.directive'; +import { ConfirmExitDialogDirective } from '../../../shared/confirm-exit-dialog.directive'; export interface CreateTransactionDialogData { type?: TransactionType; @@ -28,17 +29,18 @@ export interface CreateTransactionDialogData { templateUrl: './create-transaction-dialog.component.html', styleUrl: './create-transaction-dialog.component.scss', imports: [ - ReactiveFormsModule, - MatFormFieldModule, - MatInputModule, - MatCardModule, - MatSelectModule, MatDatepickerModule, - MatCheckboxModule, - InputClearButtonComponent, - ButtonComponent, - ValidatorComponent, - AutoTrimDirective -], + ReactiveFormsModule, + MatFormFieldModule, + MatInputModule, + MatSelectModule, MatDatepickerModule, + MatCheckboxModule, + InputClearButtonComponent, + ButtonComponent, + ValidatorComponent, + DialogCardComponent, + AutoTrimDirective, + ConfirmExitDialogDirective, + ], }) export class CreateTransactionDialogComponent extends DialogWithBaseComponent implements OnInit { private readonly transactionService = inject(TransactionService); @@ -95,9 +97,9 @@ export class CreateTransactionDialogComponent extends DialogWithBaseComponent 10 ? '...' : ''}' created successfully!`); - this.dialogRef.close(true); + this.dialogRef.submit(true); } catch (_err) { - this.dialogRef.close(false); + this.dialogRef.submit(false); } } } \ No newline at end of file diff --git a/frontend/Exence/src/app/shared/auth/guard/has-changes.guard.ts b/frontend/Exence/src/app/shared/auth/guard/has-changes.guard.ts new file mode 100644 index 0000000..28a089b --- /dev/null +++ b/frontend/Exence/src/app/shared/auth/guard/has-changes.guard.ts @@ -0,0 +1,22 @@ +import { inject } from "@angular/core"; +import { CanDeactivateFn } from "@angular/router"; +import { ConfirmExitService } from "../../confirm-exit.service"; + +export interface HasChangesComponent { + hasChanges(): boolean; +} + +export const hasChangesGuard: CanDeactivateFn = async ( + component: HasChangesComponent +) => { + const confirmExitService = inject(ConfirmExitService); + + const componentHasChanges = component.hasChanges && component.hasChanges(); + const serviceHasChanges = confirmExitService.hasChanges(); + + if (componentHasChanges || serviceHasChanges) { + return await confirmExitService.showConfirmDialog(); + } + + return true; +} \ No newline at end of file diff --git a/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts b/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts new file mode 100644 index 0000000..9ce765c --- /dev/null +++ b/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts @@ -0,0 +1,26 @@ +import { Directive, inject, input, OnInit } from "@angular/core"; +import { FormGroupDirective } from "@angular/forms"; +import { BaseComponent } from "./base-component/base.component"; +import { ConfirmExitService } from "./confirm-exit.service"; +import { DialogRef } from "./dialog/dialog.service"; + +@Directive({ + selector: '[confirmExitDialog][formGroup]', +}) +export class ConfirmExitDialogDirective extends BaseComponent implements OnInit { + private readonly formGroupDirective = inject(FormGroupDirective); + private readonly confirmExitService = inject(ConfirmExitService); + + confirmExitDialog = input.required>(); + + ngOnInit(): void { + const form = this.formGroupDirective.form; + + this.addSubscription(form.valueChanges.subscribe(() => { + this.confirmExitDialog().setLocked(form.dirty); + })); + + this.confirmExitDialog().setLocked(form.dirty); + this.confirmExitDialog().setOnCloseAttemptWhileLocked(async () => await this.confirmExitService.showConfirmDialog()); + } +} \ No newline at end of file diff --git a/frontend/Exence/src/app/shared/confirm-exit.directive.ts b/frontend/Exence/src/app/shared/confirm-exit.directive.ts new file mode 100644 index 0000000..66a9123 --- /dev/null +++ b/frontend/Exence/src/app/shared/confirm-exit.directive.ts @@ -0,0 +1,22 @@ +import { Directive, inject, OnDestroy, OnInit } from "@angular/core"; +import { FormGroupDirective } from "@angular/forms"; +import { ConfirmExitService } from "./confirm-exit.service"; + +@Directive({ + selector: '[confirmExit][formGroup]', + standalone: true, +}) +export class ConfirmExitDirective implements OnInit, OnDestroy { + private readonly formGroupDirective = inject(FormGroupDirective); + private readonly confirmExitService = inject(ConfirmExitService); + + ngOnInit(): void { + const form = this.formGroupDirective.form; + this.confirmExitService.registerForm(form); + } + + ngOnDestroy(): void { + const form = this.formGroupDirective.form; + this.confirmExitService.unregisterForm(form); + } +} \ No newline at end of file diff --git a/frontend/Exence/src/app/shared/confirm-exit.service.ts b/frontend/Exence/src/app/shared/confirm-exit.service.ts new file mode 100644 index 0000000..cbe0b76 --- /dev/null +++ b/frontend/Exence/src/app/shared/confirm-exit.service.ts @@ -0,0 +1,42 @@ +import { inject, Injectable } from "@angular/core"; +import { FormGroup } from "@angular/forms"; +import { DialogService } from "./dialog/dialog.service"; +import { MessageDialogButtonConfig, MessageDialogComponent, PredefiedButtons } from "./message-dialog/message-dialog.component"; + +@Injectable({ + providedIn: 'root' +}) +export class ConfirmExitService { + private readonly dialog = inject(DialogService); + private trackedForms = new Set(); + + registerForm(form: FormGroup): void { + this.trackedForms.add(form); + } + + unregisterForm(form: FormGroup): void { + this.trackedForms.delete(form); + } + + hasChanges(): boolean { + return Array.from(this.trackedForms).some(form => form.dirty); + } + + async showConfirmDialog(): Promise { + return this.dialog.openModal(MessageDialogComponent, { + title: 'Unsaved changes', + message: 'You have unsaved changes. Are you sure you want to leave? Your changes will be lost.', + hideCloseIcon: true, + buttons: MessageDialogButtonConfig.custom( + { + text: 'Continue', + value: true, + color: 'primary', + iconPositionEnd: true, + matIcon: 'chevron_forward', + }, + PredefiedButtons.CANCEL, + ) + }); + } +} \ No newline at end of file diff --git a/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.html b/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.html new file mode 100644 index 0000000..71422b4 --- /dev/null +++ b/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.html @@ -0,0 +1,28 @@ +
+

+ + @if (!hideCloseButton()) { + + } +

+ +

+ +

+
+ +
+ +
+ +
+ +
diff --git a/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.scss b/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.scss new file mode 100644 index 0000000..501a2ab --- /dev/null +++ b/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.scss @@ -0,0 +1,14 @@ +@import "../../../styles/imports.scss"; + +:host { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1.5rem; + + border-radius: 20px; + --bs-border-color: var(--secondary-text-color); + background-color: var(--border-color); + + overflow-x: hidden; +} \ No newline at end of file diff --git a/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.ts b/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.ts new file mode 100644 index 0000000..304be76 --- /dev/null +++ b/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.ts @@ -0,0 +1,21 @@ +import { booleanAttribute, Component, inject, input } from "@angular/core"; +import { ButtonComponent } from "../button/button.component"; +import { MatDialogRef } from "@angular/material/dialog"; + +@Component({ + selector: 'ex-dialog-card', + templateUrl: './dialog-card.component.html', + styleUrl: './dialog-card.component.scss', + imports: [ + ButtonComponent, + ], +}) +export class DialogCardComponent { + private readonly matDialogRef = inject(MatDialogRef, { optional: true }); + + hideCloseButton = input(false, { transform: booleanAttribute }); + + onCloseClick(): void { + this.matDialogRef?.close(); + } +} \ No newline at end of file diff --git a/frontend/Exence/src/app/shared/dialog/dialog.service.ts b/frontend/Exence/src/app/shared/dialog/dialog.service.ts index 8a6cb21..df34db2 100644 --- a/frontend/Exence/src/app/shared/dialog/dialog.service.ts +++ b/frontend/Exence/src/app/shared/dialog/dialog.service.ts @@ -3,7 +3,6 @@ import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dial import { firstValueFrom } from 'rxjs'; import { BaseComponent } from '../base-component/base.component'; - /** * Represents a reference to an open dialog instance. * @@ -15,14 +14,16 @@ import { BaseComponent } from '../base-component/base.component'; * including the ability to close the dialog and lock/unlock its state. * * @method onClose - Callback function invoked when the dialog closes, receiving the output value. + * @method setOnCloseAttemptWhileLocked - Function to set `_onCloseAttemptWhileLocked` value, that is used for non locked state close preventation (e.g. for showing confirm message on close event). * @method setLocked - Function to to set the locked state (availability to close the dialog) of dialog. * @field value - The input data passed to the dialog. + * @method close - Used to close the dialog with the given value as the argument. + * @method submit - Does the same as `close` method, but it works when `setOnCloseAttemptWhileLocked` was called. * * @example * ```typescript * const dialogRef = new DialogRef( * (result) => console.log('Dialog closed with:', result), - * (locked) => console.log('Dialog locked state:', locked), * { inputData: 'example' } * ); * @@ -33,14 +34,49 @@ import { BaseComponent } from '../base-component/base.component'; * dialogRef.setLocked(true); * ``` */ -class DialogRef { +export class DialogRef { + private _isLocked = false; + private _onCloseAttemptWhileLocked?: (closeValue: O) => Promise; + + get isLocked(): boolean { return this._isLocked; } + constructor( private onClose: (value: O) => void, - readonly setLocked: (isLocked: boolean) => void, - readonly value: I + readonly value: I, ) { } - public close(value: O): void { + public setLocked: (value: boolean) => void = (isLocked: boolean): void => { + this._isLocked = isLocked; + }; + + public setOnCloseAttemptWhileLocked(callback: (closeValue: unknown) => Promise): void { + this._onCloseAttemptWhileLocked = callback; + } + + public async close(value: O): Promise { + if (!this._isLocked) { + this.onClose(value); + } else if (this._onCloseAttemptWhileLocked) { + // show confirmation dialog if locked + if (await this._onCloseAttemptWhileLocked(value)) { + this.setLocked(false); + this.onClose(value); + } + } + } + + /** + * Submits the dialog form and closes the dialog. + * + * This method should only be called when the dialog has the `confirmExitDialog` directive + * applied to its form. It unlocks the dialog state and triggers the close callback with + * the provided value. This method should be bound to the primary/submit button of the dialog. + * + * @param value - The form value or result to be returned when closing the dialog + * @returns void + */ + public submit(value: O): void { + this.setLocked(false); this.onClose(value); } } @@ -98,9 +134,8 @@ export abstract class DialogWithBaseComponent ex * * @returns A new instance of the dialog component */ -type DialogConstructor = - new (dialogRef: DialogRef, ...rest: any[]) => T; - +type DialogConstructor = + new (dialogRef: DialogRef, ...rest: unknown[]) => T; /** * Represents a dialog that can be opened in the application. @@ -147,7 +182,6 @@ type DialogConstructor = */ type Dialog = DialogConstructor | TemplateRef; - /** * Configuration settings for Exence's custom dialogs, excluding injector and disableClose properties. * @@ -263,11 +297,7 @@ export class DialogService extends BaseComponent { let locked = settings.disableClose === true; const dialogRef = new DialogRef( - (value) => matDialogRef?.close(value), - (value) => { - locked = value; - matDialogRef!.disableClose = value; - }, + (value: O) => matDialogRef?.close(value), settings.value, ); @@ -286,6 +316,22 @@ export class DialogService extends BaseComponent { } ); + const originalClose = matDialogRef.close.bind(matDialogRef); + matDialogRef.close = async (dialogResult?: any) => { + if (!dialogRef.isLocked) { + originalClose(dialogResult); // not locked call original close + } else { + await dialogRef.close(dialogResult); // call close with lock handling + } + }; + + // overrides setLocked method + const originalSetLocked = dialogRef.setLocked.bind(dialogRef); + dialogRef.setLocked = (value: boolean) => { + if (matDialogRef) matDialogRef.disableClose = locked || value; + originalSetLocked(value); + }; + if (settings.disableClose !== true) { const value = settings.disableClose.defaultValue; this.addSubscription(matDialogRef.backdropClick().subscribe(() => { diff --git a/frontend/Exence/src/app/shared/message-dialog/message-dialog.component.html b/frontend/Exence/src/app/shared/message-dialog/message-dialog.component.html new file mode 100644 index 0000000..ea80d9f --- /dev/null +++ b/frontend/Exence/src/app/shared/message-dialog/message-dialog.component.html @@ -0,0 +1,30 @@ + +
{{ dialogRef.value.title }}
+ +
+ {{ dialogRef.value.message }} +
+ +
+ @for (button of actions.buttons; track button.text) { + + {{ button.text }} + + } +
+
diff --git a/frontend/Exence/src/app/shared/message-dialog/message-dialog.component.ts b/frontend/Exence/src/app/shared/message-dialog/message-dialog.component.ts new file mode 100644 index 0000000..95cddff --- /dev/null +++ b/frontend/Exence/src/app/shared/message-dialog/message-dialog.component.ts @@ -0,0 +1,62 @@ +import { Component } from "@angular/core"; +import { ButtonComponent } from "../button/button.component"; +import { DialogCardComponent } from "../dialog-card/dialog-card.component"; +import { DialogComponent } from "../dialog/dialog.service"; +import { SvgIcons } from "../svg-icons/svg-icons"; +import { MatDialogClose } from "@angular/material/dialog"; + +enum PredefinedButtonNames{ + OK = 'OK', + CANCEL = 'CANCEL', + DELETE = 'DELETE', +} + +export const PredefiedButtons: Record = { + OK: { text: 'Ok', value: true, matIcon: 'check', color: 'primary' }, + CANCEL: { text: 'Cancel', value: false, matIcon: 'close', color: 'accent' }, + DELETE: { text: 'Delete', value: true, matIcon: 'delete', color: 'error' }, +} + +export interface MessageDialogButtonData { + text: string; + value: boolean; + color: 'primary' | 'accent' | 'success' | 'error' | 'warn'; + svgIcon?: SvgIcons; + matIcon?: string; + iconPositionEnd?: true; +} + +export interface MessageDialogData { + title: string; + message: string; + hideCloseIcon: boolean; + buttons: MessageDialogButtonConfig; +} + +export class MessageDialogButtonConfig { + public static ok = new MessageDialogButtonConfig(PredefiedButtons.OK); + public static okCancel = new MessageDialogButtonConfig(PredefiedButtons.CANCEL, PredefiedButtons.OK); + public static delete = new MessageDialogButtonConfig(PredefiedButtons.DELETE); + public static deleteCancel = new MessageDialogButtonConfig(PredefiedButtons.CANCEL, PredefiedButtons.DELETE); + + readonly buttons: MessageDialogButtonData[]; + constructor(...buttons: MessageDialogButtonData[]) { this.buttons = buttons; } + + static custom(primaryButton: MessageDialogButtonData, secondaryButton?: MessageDialogButtonData): MessageDialogButtonConfig { + if (secondaryButton) return new MessageDialogButtonConfig(secondaryButton, primaryButton); + return new MessageDialogButtonConfig(primaryButton); + } +} + +@Component({ + selector: 'ex-message-dialog', + templateUrl: './message-dialog.component.html', + imports: [ + DialogCardComponent, + ButtonComponent, + MatDialogClose +], +}) +export class MessageDialogComponent extends DialogComponent { + actions = this.dialogRef.value.buttons ?? MessageDialogButtonConfig.okCancel; +} \ No newline at end of file diff --git a/frontend/Exence/src/styles/styles.scss b/frontend/Exence/src/styles/styles.scss index 5aedb74..6249e8f 100644 --- a/frontend/Exence/src/styles/styles.scss +++ b/frontend/Exence/src/styles/styles.scss @@ -123,3 +123,7 @@ hr { mat-card.mat-mdc-card.categories mat-card-header .mat-mdc-card-header-text { width: 100%; } + +mat-checkbox { + --mat-checkbox-touch-target-display: none; +} \ No newline at end of file