From 19444f52403e45b30072b5c547d6b0c9b26c0423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Mon, 12 Jan 2026 15:09:24 +0100 Subject: [PATCH 1/9] #EX-204: Add dialog-card.component and migrate card inside dialogs --- .../create-category-dialog.component.html | 21 ++++++-------- .../create-category-dialog.component.scss | 7 +++++ .../create-category-dialog.component.ts | 28 +++++++++---------- .../create-transaction-dialog.component.html | 21 ++++++-------- .../create-transaction-dialog.component.scss | 7 +++++ .../create-transaction-dialog.component.ts | 24 ++++++++-------- .../dialog-card/dialog-card.component.html | 28 +++++++++++++++++++ .../dialog-card/dialog-card.component.scss | 14 ++++++++++ .../dialog-card/dialog-card.component.ts | 16 +++++++++++ 9 files changed, 116 insertions(+), 50 deletions(-) create mode 100644 frontend/Exence/src/app/shared/dialog-card/dialog-card.component.html create mode 100644 frontend/Exence/src/app/shared/dialog-card/dialog-card.component.scss create mode 100644 frontend/Exence/src/app/shared/dialog-card/dialog-card.component.ts 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..59982a6 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); diff --git a/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.html b/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.html index 1c9875b..237be5a 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.html +++ b/frontend/Exence/src/app/private/transactions-and-categories/create-transaction-dialog/create-transaction-dialog.component.html @@ -1,9 +1,7 @@ - - -

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..241fcb6 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,6 +9,7 @@ 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'; @@ -28,17 +28,17 @@ 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, + ], }) export class CreateTransactionDialogComponent extends DialogWithBaseComponent implements OnInit { private readonly transactionService = inject(TransactionService); 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..1275cf2 --- /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..8736734 --- /dev/null +++ b/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.ts @@ -0,0 +1,16 @@ +import { booleanAttribute, Component, input } from "@angular/core"; +import { ButtonComponent } from "../button/button.component"; +import { MatDialogClose } from "@angular/material/dialog"; + +@Component({ + selector: 'ex-dialog-card', + templateUrl: './dialog-card.component.html', + styleUrl: './dialog-card.component.scss', + imports: [ + MatDialogClose, + ButtonComponent, + ], +}) +export class DialogCardComponent { + hideCloseButton = input(false, { transform: booleanAttribute }); +} \ No newline at end of file From c57798f25feec1c7414af22b220f7e933f16ca98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Mon, 12 Jan 2026 17:05:00 +0100 Subject: [PATCH 2/9] #EX-210: Add message-dialog.component for easy confirmation dialogs --- .../message-dialog.component.html | 30 +++++++++ .../message-dialog.component.ts | 62 +++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 frontend/Exence/src/app/shared/message-dialog/message-dialog.component.html create mode 100644 frontend/Exence/src/app/shared/message-dialog/message-dialog.component.ts 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 From e8bc93a3d9e325da6a8ffad3a7aa6aa6bfded86f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Tue, 13 Jan 2026 19:44:38 +0100 Subject: [PATCH 3/9] #EX-208: Add has-changed.guard for form change detection Detects if the component passed to the route has changed with an interface and hasChanges method --- .../shared/auth/guard/has-changes.guard.ts | 19 +++++++++++++ .../src/app/shared/confirm-exit.service.ts | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 frontend/Exence/src/app/shared/auth/guard/has-changes.guard.ts create mode 100644 frontend/Exence/src/app/shared/confirm-exit.service.ts 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..cdfaac4 --- /dev/null +++ b/frontend/Exence/src/app/shared/auth/guard/has-changes.guard.ts @@ -0,0 +1,19 @@ +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); + + console.log(component.hasChanges ? component.hasChanges() : 'undefined') + if (!component.hasChanges || !component.hasChanges()) return true; + + const result = await confirmExitService.showConfirmDialog(); + return result; +} \ 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..8f32816 --- /dev/null +++ b/frontend/Exence/src/app/shared/confirm-exit.service.ts @@ -0,0 +1,28 @@ +import { inject, Injectable } from "@angular/core"; +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); + + 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 From fbe1b1e842cb539f0df1c741a8d4b9a1939107e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Tue, 13 Jan 2026 19:53:34 +0100 Subject: [PATCH 4/9] #EX-208: Add confirm-exit.directive to handle change detection This handles changes on non-dialog form changes, works with has-changes.guard. --- .../shared/auth/guard/has-changes.guard.ts | 11 ++++++---- .../src/app/shared/confirm-exit.directive.ts | 22 +++++++++++++++++++ .../src/app/shared/confirm-exit.service.ts | 14 ++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 frontend/Exence/src/app/shared/confirm-exit.directive.ts 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 index cdfaac4..28a089b 100644 --- a/frontend/Exence/src/app/shared/auth/guard/has-changes.guard.ts +++ b/frontend/Exence/src/app/shared/auth/guard/has-changes.guard.ts @@ -11,9 +11,12 @@ export const hasChangesGuard: CanDeactivateFn = async ( ) => { const confirmExitService = inject(ConfirmExitService); - console.log(component.hasChanges ? component.hasChanges() : 'undefined') - if (!component.hasChanges || !component.hasChanges()) return true; + const componentHasChanges = component.hasChanges && component.hasChanges(); + const serviceHasChanges = confirmExitService.hasChanges(); + + if (componentHasChanges || serviceHasChanges) { + return await confirmExitService.showConfirmDialog(); + } - const result = await confirmExitService.showConfirmDialog(); - return result; + return true; } \ 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 index 8f32816..cbe0b76 100644 --- a/frontend/Exence/src/app/shared/confirm-exit.service.ts +++ b/frontend/Exence/src/app/shared/confirm-exit.service.ts @@ -1,4 +1,5 @@ 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"; @@ -7,6 +8,19 @@ import { MessageDialogButtonConfig, MessageDialogComponent, PredefiedButtons } f }) 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, { From 52f76f34d34cf9ec2e3afab60afd54ef8f6af8db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Tue, 13 Jan 2026 20:56:00 +0100 Subject: [PATCH 5/9] #EX-208: Precent dialog with form from closing when form is dirty No actual UI event, we just don't let the user leave the form. Created new confirmExitDialog directive for forms shown on dialogs. --- .../shared/confirm-exit-dialog.directive.ts | 32 ++++++++++++++++ .../dialog-card/dialog-card.component.html | 2 +- .../dialog-card/dialog-card.component.ts | 11 ++++-- .../src/app/shared/dialog/dialog.service.ts | 37 +++++++++++++------ 4 files changed, 67 insertions(+), 15 deletions(-) create mode 100644 frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts 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..e67cfe7 --- /dev/null +++ b/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts @@ -0,0 +1,32 @@ +import { Directive, Input, OnDestroy, OnInit } from "@angular/core"; +import { FormGroupDirective } from "@angular/forms"; +import { Subscription } from "rxjs"; +import { DialogRef } from "./dialog/dialog.service"; + +@Directive({ + selector: '[confirmExitDialog][formGroup]', +}) +export class ConfirmExitDialogDirective implements OnInit, OnDestroy { + private subscription?: Subscription; + + @Input({ required: true }) + confirmExitDialog!: DialogRef; + + constructor(private readonly formGroupDirective: FormGroupDirective) { } + + ngOnInit(): void { + const form = this.formGroupDirective.form; + + this.subscription?.unsubscribe(); + + this.subscription = form.valueChanges.subscribe(() => { + this.confirmExitDialog.setLocked(form.dirty); + }); + + this.confirmExitDialog.setLocked(form.dirty); + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe(); + } +} \ 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 index 1275cf2..71422b4 100644 --- a/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.html +++ b/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.html @@ -8,7 +8,7 @@ } 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 index 8736734..304be76 100644 --- a/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.ts +++ b/frontend/Exence/src/app/shared/dialog-card/dialog-card.component.ts @@ -1,16 +1,21 @@ -import { booleanAttribute, Component, input } from "@angular/core"; +import { booleanAttribute, Component, inject, input } from "@angular/core"; import { ButtonComponent } from "../button/button.component"; -import { MatDialogClose } from "@angular/material/dialog"; +import { MatDialogRef } from "@angular/material/dialog"; @Component({ selector: 'ex-dialog-card', templateUrl: './dialog-card.component.html', styleUrl: './dialog-card.component.scss', imports: [ - MatDialogClose, 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..dcf3033 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. * @@ -33,14 +32,24 @@ import { BaseComponent } from '../base-component/base.component'; * dialogRef.setLocked(true); * ``` */ -class DialogRef { +export class DialogRef { + private _isLocked = false; + constructor( private onClose: (value: O) => void, - readonly setLocked: (isLocked: boolean) => void, - readonly value: I + readonly value: I, ) { } + public setLocked(isLocked: boolean): void { + this._isLocked = isLocked; + } + public close(value: O): void { + if (!this._isLocked) this.onClose(value); + } + + public submit(value: O): void { + this.setLocked(false); this.onClose(value); } } @@ -101,7 +110,6 @@ export abstract class DialogWithBaseComponent ex type DialogConstructor = new (dialogRef: DialogRef, ...rest: any[]) => T; - /** * Represents a dialog that can be opened in the application. * @@ -147,7 +155,6 @@ type DialogConstructor = */ type Dialog = DialogConstructor | TemplateRef; - /** * Configuration settings for Exence's custom dialogs, excluding injector and disableClose properties. * @@ -263,11 +270,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 +289,18 @@ export class DialogService extends BaseComponent { } ); + const originalMatDialogClose = matDialogRef.close.bind(matDialogRef); + matDialogRef.close = (dialogResult?: any) => { + if (!locked) originalMatDialogClose(dialogResult); + }; + + const originalSetLocked = dialogRef.setLocked.bind(dialogRef); + dialogRef.setLocked = (value: boolean) => { + locked = value; + if (matDialogRef) matDialogRef.disableClose = value; + originalSetLocked(value); + }; + if (settings.disableClose !== true) { const value = settings.disableClose.defaultValue; this.addSubscription(matDialogRef.backdropClick().subscribe(() => { From 5b8673660dbb6e93cc01db85bb3f7eb1cefa98ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Tue, 13 Jan 2026 21:18:27 +0100 Subject: [PATCH 6/9] #EX-208: Add confirm dialog on close attempt dialog with dirty form --- .../create-category-dialog.component.html | 2 +- .../create-category-dialog.component.ts | 6 ++-- .../create-transaction-dialog.component.html | 4 +-- .../create-transaction-dialog.component.ts | 8 +++-- .../shared/confirm-exit-dialog.directive.ts | 7 ++++- .../src/app/shared/dialog/dialog.service.ts | 31 +++++++++++++++---- 6 files changed, 43 insertions(+), 15 deletions(-) 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 59982a6..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,7 +1,7 @@
New category
-
+ @let nameControl = form.controls.name; Name diff --git a/frontend/Exence/src/app/private/transactions-and-categories/create-category-dialog/create-category-dialog.component.ts b/frontend/Exence/src/app/private/transactions-and-categories/create-category-dialog/create-category-dialog.component.ts index 9a7fec9..5f34b9f 100644 --- a/frontend/Exence/src/app/private/transactions-and-categories/create-category-dialog/create-category-dialog.component.ts +++ b/frontend/Exence/src/app/private/transactions-and-categories/create-category-dialog/create-category-dialog.component.ts @@ -15,6 +15,7 @@ import { SnackbarService } from '../../../shared/snackbar/snackbar.service'; import { ValidatorComponent } from '../../../shared/validator/validator.component'; import { CategoryService } from '../../category.service'; import { AutoTrimDirective } from '../../../shared/auto-trim.directive'; +import { ConfirmExitDialogDirective } from '../../../shared/confirm-exit-dialog.directive'; @Component({ selector: 'ex-create-category-dialog', @@ -32,6 +33,7 @@ import { AutoTrimDirective } from '../../../shared/auto-trim.directive'; ValidatorComponent, DialogCardComponent, AutoTrimDirective, + ConfirmExitDialogDirective, ], }) export class CreateCategoryDialogComponent extends DialogComponent { @@ -75,9 +77,9 @@ export class CreateCategoryDialogComponent extends DialogComponent
New transaction
-
+ @let titleControl = form.controls.title; Title @@ -86,7 +86,7 @@ @let recurringControl = form.controls.recurring; Automatically recurring? -
+
implements OnInit { @@ -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/confirm-exit-dialog.directive.ts b/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts index e67cfe7..1312d79 100644 --- a/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts +++ b/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts @@ -2,6 +2,7 @@ import { Directive, Input, OnDestroy, OnInit } from "@angular/core"; import { FormGroupDirective } from "@angular/forms"; import { Subscription } from "rxjs"; import { DialogRef } from "./dialog/dialog.service"; +import { ConfirmExitService } from "./confirm-exit.service"; @Directive({ selector: '[confirmExitDialog][formGroup]', @@ -12,7 +13,10 @@ export class ConfirmExitDialogDirective implements OnInit, OnDestroy { @Input({ required: true }) confirmExitDialog!: DialogRef; - constructor(private readonly formGroupDirective: FormGroupDirective) { } + constructor( + private readonly formGroupDirective: FormGroupDirective, + private readonly confirmExitService: ConfirmExitService, + ) { } ngOnInit(): void { const form = this.formGroupDirective.form; @@ -24,6 +28,7 @@ export class ConfirmExitDialogDirective implements OnInit, OnDestroy { }); this.confirmExitDialog.setLocked(form.dirty); + this.confirmExitDialog.setOnCloseAttemptWhileLocked(async () => await this.confirmExitService.showConfirmDialog()); } ngOnDestroy(): void { diff --git a/frontend/Exence/src/app/shared/dialog/dialog.service.ts b/frontend/Exence/src/app/shared/dialog/dialog.service.ts index dcf3033..ce58bdc 100644 --- a/frontend/Exence/src/app/shared/dialog/dialog.service.ts +++ b/frontend/Exence/src/app/shared/dialog/dialog.service.ts @@ -34,6 +34,9 @@ import { BaseComponent } from '../base-component/base.component'; */ export class DialogRef { private _isLocked = false; + private _onCloseAttemptWhileLocked?: (closeValue: O) => Promise; + + get isLocked(): boolean { return this._isLocked; } constructor( private onClose: (value: O) => void, @@ -44,8 +47,21 @@ export class DialogRef { this._isLocked = isLocked; } - public close(value: O): void { - if (!this._isLocked) this.onClose(value); + public setOnCloseAttemptWhileLocked(callback: (closeValue: any) => Promise): void { + this._onCloseAttemptWhileLocked = callback; + } + + public async close(value: O): Promise { + if (!this._isLocked) { + this.onClose(value); + } else if (this._onCloseAttemptWhileLocked) { + // Show confirmation dialog + const shouldClose = await this._onCloseAttemptWhileLocked(value); + if (shouldClose) { + this.setLocked(false); + this.onClose(value); + } + } } public submit(value: O): void { @@ -290,14 +306,17 @@ export class DialogService extends BaseComponent { ); const originalMatDialogClose = matDialogRef.close.bind(matDialogRef); - matDialogRef.close = (dialogResult?: any) => { - if (!locked) originalMatDialogClose(dialogResult); + matDialogRef.close = async (dialogResult?: any) => { + if (!dialogRef.isLocked) { + originalMatDialogClose(dialogResult); + } else { + await dialogRef.close(dialogResult); + } }; const originalSetLocked = dialogRef.setLocked.bind(dialogRef); dialogRef.setLocked = (value: boolean) => { - locked = value; - if (matDialogRef) matDialogRef.disableClose = value; + if (matDialogRef) matDialogRef.disableClose = locked || value; originalSetLocked(value); }; From 02a89c5551c608fff24d212c408059b2d5c9548d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Tue, 13 Jan 2026 21:54:02 +0100 Subject: [PATCH 7/9] #EX-208: Refactor confirmExitDialog directive, dialog.service --- .../shared/confirm-exit-dialog.directive.ts | 35 +++++++------------ .../src/app/shared/dialog/dialog.service.ts | 27 +++++++++----- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts b/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts index 1312d79..9ce765c 100644 --- a/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts +++ b/frontend/Exence/src/app/shared/confirm-exit-dialog.directive.ts @@ -1,37 +1,26 @@ -import { Directive, Input, OnDestroy, OnInit } from "@angular/core"; +import { Directive, inject, input, OnInit } from "@angular/core"; import { FormGroupDirective } from "@angular/forms"; -import { Subscription } from "rxjs"; -import { DialogRef } from "./dialog/dialog.service"; +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 implements OnInit, OnDestroy { - private subscription?: Subscription; - - @Input({ required: true }) - confirmExitDialog!: DialogRef; +export class ConfirmExitDialogDirective extends BaseComponent implements OnInit { + private readonly formGroupDirective = inject(FormGroupDirective); + private readonly confirmExitService = inject(ConfirmExitService); - constructor( - private readonly formGroupDirective: FormGroupDirective, - private readonly confirmExitService: ConfirmExitService, - ) { } + confirmExitDialog = input.required>(); ngOnInit(): void { const form = this.formGroupDirective.form; - this.subscription?.unsubscribe(); + this.addSubscription(form.valueChanges.subscribe(() => { + this.confirmExitDialog().setLocked(form.dirty); + })); - this.subscription = form.valueChanges.subscribe(() => { - this.confirmExitDialog.setLocked(form.dirty); - }); - - this.confirmExitDialog.setLocked(form.dirty); - this.confirmExitDialog.setOnCloseAttemptWhileLocked(async () => await this.confirmExitService.showConfirmDialog()); - } - - ngOnDestroy(): void { - this.subscription?.unsubscribe(); + 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/dialog/dialog.service.ts b/frontend/Exence/src/app/shared/dialog/dialog.service.ts index ce58bdc..3c56b70 100644 --- a/frontend/Exence/src/app/shared/dialog/dialog.service.ts +++ b/frontend/Exence/src/app/shared/dialog/dialog.service.ts @@ -43,9 +43,9 @@ export class DialogRef { readonly value: I, ) { } - public setLocked(isLocked: boolean): void { + public setLocked: (value: boolean) => void = (isLocked: boolean): void => { this._isLocked = isLocked; - } + }; public setOnCloseAttemptWhileLocked(callback: (closeValue: any) => Promise): void { this._onCloseAttemptWhileLocked = callback; @@ -55,15 +55,25 @@ export class DialogRef { if (!this._isLocked) { this.onClose(value); } else if (this._onCloseAttemptWhileLocked) { - // Show confirmation dialog - const shouldClose = await this._onCloseAttemptWhileLocked(value); - if (shouldClose) { + // 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); @@ -305,15 +315,16 @@ export class DialogService extends BaseComponent { } ); - const originalMatDialogClose = matDialogRef.close.bind(matDialogRef); + const originalClose = matDialogRef.close.bind(matDialogRef); matDialogRef.close = async (dialogResult?: any) => { if (!dialogRef.isLocked) { - originalMatDialogClose(dialogResult); + originalClose(dialogResult); // not locked call original close } else { - await dialogRef.close(dialogResult); + 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; From 1386294f02d21391df1c40111b998d8e623790b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Sat, 17 Jan 2026 21:46:10 +0100 Subject: [PATCH 8/9] Fix lint errors, update dialog.service js docs --- .../Exence/src/app/shared/dialog/dialog.service.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/Exence/src/app/shared/dialog/dialog.service.ts b/frontend/Exence/src/app/shared/dialog/dialog.service.ts index 3c56b70..df34db2 100644 --- a/frontend/Exence/src/app/shared/dialog/dialog.service.ts +++ b/frontend/Exence/src/app/shared/dialog/dialog.service.ts @@ -14,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' } * ); * @@ -47,7 +49,7 @@ export class DialogRef { this._isLocked = isLocked; }; - public setOnCloseAttemptWhileLocked(callback: (closeValue: any) => Promise): void { + public setOnCloseAttemptWhileLocked(callback: (closeValue: unknown) => Promise): void { this._onCloseAttemptWhileLocked = callback; } @@ -63,7 +65,6 @@ export class DialogRef { } } - /** * Submits the dialog form and closes the dialog. * @@ -133,8 +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. From 19ca3ad099917c006603eb9603bfdac4cffe8b0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20Nagy?= Date: Sat, 17 Jan 2026 21:46:27 +0100 Subject: [PATCH 9/9] Fix scrollbar always showing on create-transaction-dialog.component --- frontend/Exence/src/styles/styles.scss | 4 ++++ 1 file changed, 4 insertions(+) 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