diff --git a/src/app/child-dev-project/notes/note-details/note-details.component.spec.ts b/src/app/child-dev-project/notes/note-details/note-details.component.spec.ts index 55044a6d5d..8942bb7ab3 100644 --- a/src/app/child-dev-project/notes/note-details/note-details.component.spec.ts +++ b/src/app/child-dev-project/notes/note-details/note-details.component.spec.ts @@ -7,6 +7,7 @@ import { defaultAttendanceStatusTypes } from "../../../core/config/default-confi import { MockedTestingModule } from "../../../utils/mocked-testing.module"; import { LoginState } from "../../../core/session/session-states/login-state.enum"; import { EntityConfigService } from "../../../core/entity/entity-config.service"; +import { NEVER } from "rxjs"; function generateTestNote(forChildren: Child[]) { const testNote = Note.create(new Date(), "test note"); @@ -42,7 +43,10 @@ describe("NoteDetailsComponent", () => { ], providers: [ { provide: MAT_DIALOG_DATA, useValue: { entity: testNote } }, - { provide: MatDialogRef, useValue: {} }, + { + provide: MatDialogRef, + useValue: { backdropClick: () => NEVER, afterClosed: () => NEVER }, + }, ], }).compileComponents(); })); diff --git a/src/app/child-dev-project/notes/note-details/note-details.stories.ts b/src/app/child-dev-project/notes/note-details/note-details.stories.ts index b7b8763b87..e708848052 100644 --- a/src/app/child-dev-project/notes/note-details/note-details.stories.ts +++ b/src/app/child-dev-project/notes/note-details/note-details.stories.ts @@ -5,7 +5,7 @@ import { Note } from "../model/note"; import { Child } from "../../children/model/child"; import { MAT_DIALOG_DATA, MatDialogRef } from "@angular/material/dialog"; import { ChildrenService } from "../../children/children.service"; -import { of } from "rxjs"; +import { NEVER, of } from "rxjs"; import { MockedTestingModule } from "../../../utils/mocked-testing.module"; import { StorybookBaseModule } from "../../../utils/storybook-base.module"; @@ -26,7 +26,10 @@ export default { provide: MAT_DIALOG_DATA, useValue: { data: { entity: Note.create(new Date()) } }, }, - { provide: MatDialogRef, useValue: {} }, + { + provide: MatDialogRef, + useValue: { backdropClick: () => NEVER, afterClosed: () => NEVER }, + }, { provide: ChildrenService, useValue: { getChild: () => of(Child.create("John Doe")) }, diff --git a/src/app/core/confirmation-dialog/confirmation-dialog.service.ts b/src/app/core/confirmation-dialog/confirmation-dialog.service.ts index b2ed731714..f41fbea700 100644 --- a/src/app/core/confirmation-dialog/confirmation-dialog.service.ts +++ b/src/app/core/confirmation-dialog/confirmation-dialog.service.ts @@ -55,4 +55,11 @@ export class ConfirmationDialogService { .afterClosed() ); } + + getDiscardConfirmation() { + return this.getConfirmation( + $localize`:Discard changes header:Discard Changes?`, + $localize`:Discard changes message:You have unsaved changes. Do you really want to leave this page? All unsaved changes will be lost.` + ); + } } diff --git a/src/app/core/entity-components/entity-details/entity-details.component.html b/src/app/core/entity-components/entity-details/entity-details.component.html index 1045cef485..808257d9e0 100644 --- a/src/app/core/entity-components/entity-details/entity-details.component.html +++ b/src/app/core/entity-components/entity-details/entity-details.component.html @@ -60,7 +60,7 @@ ) => { this.config = data.config; diff --git a/src/app/core/entity-components/entity-details/form/unsaved-changes.service.spec.ts b/src/app/core/entity-components/entity-details/form/unsaved-changes.service.spec.ts new file mode 100644 index 0000000000..cfe73071ba --- /dev/null +++ b/src/app/core/entity-components/entity-details/form/unsaved-changes.service.spec.ts @@ -0,0 +1,55 @@ +import { TestBed } from "@angular/core/testing"; + +import { UnsavedChangesService } from "./unsaved-changes.service"; +import { ConfirmationDialogService } from "../../../confirmation-dialog/confirmation-dialog.service"; + +describe("UnsavedChangesService", () => { + let service: UnsavedChangesService; + let mockConfirmation: jasmine.SpyObj; + + beforeEach(() => { + mockConfirmation = jasmine.createSpyObj(["getDiscardConfirmation"]); + TestBed.configureTestingModule({ + providers: [ + { provide: ConfirmationDialogService, useValue: mockConfirmation }, + ], + }); + service = TestBed.inject(UnsavedChangesService); + }); + + it("should be created", () => { + expect(service).toBeTruthy(); + }); + + it("should only ask for confirmation if changes are pending", async () => { + mockConfirmation.getDiscardConfirmation.and.resolveTo(false); + + await expectAsync(service.checkUnsavedChanges()).toBeResolvedTo(true); + expect(mockConfirmation.getDiscardConfirmation).not.toHaveBeenCalled(); + + service.pending = true; + + await expectAsync(service.checkUnsavedChanges()).toBeResolvedTo(false); + expect(mockConfirmation.getDiscardConfirmation).toHaveBeenCalled(); + + mockConfirmation.getDiscardConfirmation.and.resolveTo(true); + + await expectAsync(service.checkUnsavedChanges()).toBeResolvedTo(true); + }); + + it("should prevent closing the window if changes are pending", () => { + const e = { preventDefault: jasmine.createSpy(), returnValue: undefined }; + + service.pending = false; + window.onbeforeunload(e as any); + + expect(e.preventDefault).not.toHaveBeenCalled(); + expect(e.returnValue).toBeUndefined(); + + service.pending = true; + window.onbeforeunload(e as any); + + expect(e.preventDefault).toHaveBeenCalled(); + expect(e.returnValue).toBe("onbeforeunload"); + }); +}); diff --git a/src/app/core/entity-components/entity-details/form/unsaved-changes.service.ts b/src/app/core/entity-components/entity-details/form/unsaved-changes.service.ts new file mode 100644 index 0000000000..a4287f81f5 --- /dev/null +++ b/src/app/core/entity-components/entity-details/form/unsaved-changes.service.ts @@ -0,0 +1,44 @@ +import { Injectable } from "@angular/core"; +import { ConfirmationDialogService } from "../../../confirmation-dialog/confirmation-dialog.service"; + +/** + * This service handles the state whether there are currently some unsaved changes in the app. + * These pending changes might come from a form component or popup. + * If there are pending changes, certain actions in the app should trigger a user confirmation if the changes should be discarded. + */ +@Injectable({ + providedIn: "root", +}) +export class UnsavedChangesService { + /** + * Set to true if the user has pending changes that are not yet saved. + * Set to false once the changes have been saved or discarded. + */ + pending = false; + + constructor(private confirmation: ConfirmationDialogService) { + // prevent browser navigation if changes are pending + window.onbeforeunload = (e) => { + if (this.pending) { + e.preventDefault(); + e.returnValue = "onbeforeunload"; + } + }; + } + + /** + * Shows a user confirmation popup if there are unsaved changes which will be discarded. + */ + async checkUnsavedChanges() { + if (this.pending) { + const confirmed = await this.confirmation.getDiscardConfirmation(); + if (confirmed) { + this.pending = false; + return true; + } else { + return false; + } + } + return true; + } +} diff --git a/src/app/core/entity-components/entity-form/entity-form.service.spec.ts b/src/app/core/entity-components/entity-form/entity-form.service.spec.ts index 8e0c3ea09b..2bf9f0c50c 100644 --- a/src/app/core/entity-components/entity-form/entity-form.service.spec.ts +++ b/src/app/core/entity-components/entity-form/entity-form.service.spec.ts @@ -14,6 +14,10 @@ import { School } from "../../../child-dev-project/schools/model/school"; import { ChildSchoolRelation } from "../../../child-dev-project/children/model/childSchoolRelation"; import { EntityAbility } from "../../permissions/ability/entity-ability"; import { InvalidFormFieldError } from "./invalid-form-field.error"; +import { MatDialogModule } from "@angular/material/dialog"; +import { UnsavedChangesService } from "../entity-details/form/unsaved-changes.service"; +import { Router } from "@angular/router"; +import { NotFoundComponent } from "../../view/dynamic-routing/not-found/not-found.component"; describe("EntityFormService", () => { let service: EntityFormService; @@ -23,6 +27,7 @@ describe("EntityFormService", () => { mockEntityMapper = jasmine.createSpyObj(["save"]); mockEntityMapper.save.and.resolveTo(); TestBed.configureTestingModule({ + imports: [MatDialogModule], providers: [ FormBuilder, EntitySchemaService, @@ -107,7 +112,6 @@ describe("EntityFormService", () => { it("should create a error if form is invalid", () => { const formFields = [{ id: "schoolId" }, { id: "start" }]; - service.extendFormFieldConfig(formFields, ChildSchoolRelation); const formGroup = service.createFormGroup( formFields, new ChildSchoolRelation() @@ -117,4 +121,51 @@ describe("EntityFormService", () => { service.saveChanges(formGroup, new ChildSchoolRelation()) ).toBeRejectedWith(jasmine.any(InvalidFormFieldError)); }); + + it("should set pending changes once a form is edited and reset it once saved or canceled", async () => { + const formFields = [{ id: "inactive" }]; + const formGroup = service.createFormGroup(formFields, new Entity()); + const unsavedChanges = TestBed.inject(UnsavedChangesService); + + formGroup.markAsDirty(); + formGroup.get("inactive").setValue(true); + expect(unsavedChanges.pending).toBeTrue(); + + TestBed.inject(EntityAbility).update([ + { action: "manage", subject: "all" }, + ]); + await service.saveChanges(formGroup, new Entity()); + + expect(unsavedChanges.pending).toBeFalse(); + + formGroup.markAsDirty(); + formGroup.get("inactive").setValue(true); + expect(unsavedChanges.pending).toBeTrue(); + + service.resetForm(formGroup, new Entity()); + + expect(unsavedChanges.pending).toBeFalse(); + }); + + it("should reset state once navigation happens", async () => { + const router = TestBed.inject(Router); + router.resetConfig([{ path: "test", component: NotFoundComponent }]); + const unsavedChanged = TestBed.inject(UnsavedChangesService); + const formFields = [{ id: "inactive" }]; + const formGroup = service.createFormGroup(formFields, new Entity()); + formGroup.markAsDirty(); + formGroup.get("inactive").setValue(true); + + expect(unsavedChanged.pending).toBeTrue(); + + await router.navigate(["test"]); + + expect(unsavedChanged.pending).toBeFalse(); + + // Changes are not listened to anymore + formGroup.markAsDirty(); + formGroup.get("inactive").setValue(true); + + expect(unsavedChanged.pending).toBeFalse(); + }); }); diff --git a/src/app/core/entity-components/entity-form/entity-form.service.ts b/src/app/core/entity-components/entity-form/entity-form.service.ts index 99efa6ef34..be650962cf 100644 --- a/src/app/core/entity-components/entity-form/entity-form.service.ts +++ b/src/app/core/entity-components/entity-form/entity-form.service.ts @@ -8,6 +8,10 @@ import { DynamicValidatorsService } from "./dynamic-form-validators/dynamic-vali import { EntityAbility } from "../../permissions/ability/entity-ability"; import { InvalidFormFieldError } from "./invalid-form-field.error"; import { omit } from "lodash-es"; +import { UnsavedChangesService } from "../entity-details/form/unsaved-changes.service"; +import { ActivationStart, Router } from "@angular/router"; +import { Subscription } from "rxjs"; +import { filter } from "rxjs/operators"; /** * These are utility types that allow to define the type of `FormGroup` the way it is returned by `EntityFormService.create` @@ -21,13 +25,26 @@ export type EntityForm = TypedForm>; */ @Injectable({ providedIn: "root" }) export class EntityFormService { + private subscriptions: Subscription[] = []; + constructor( private fb: FormBuilder, private entityMapper: EntityMapperService, private entitySchemaService: EntitySchemaService, private dynamicValidator: DynamicValidatorsService, - private ability: EntityAbility - ) {} + private ability: EntityAbility, + private unsavedChanges: UnsavedChangesService, + router: Router + ) { + router.events + .pipe(filter((e) => e instanceof ActivationStart)) + .subscribe(() => { + // Clean up everything once navigation happens + this.subscriptions.forEach((sub) => sub.unsubscribe()); + this.subscriptions = []; + this.unsavedChanges.pending = false; + }); + } /** * Uses schema information to fill missing fields in the FormFieldConfig. @@ -106,7 +123,12 @@ export class EntityFormService { formConfig[formField.id].push(validators); } }); - return this.fb.group>(formConfig); + const group = this.fb.group>(formConfig); + const sub = group.valueChanges.subscribe( + () => (this.unsavedChanges.pending = group.dirty) + ); + this.subscriptions.push(sub); + return group; } /** @@ -133,7 +155,10 @@ export class EntityFormService { return this.entityMapper .save(updatedEntity) - .then(() => Object.assign(entity, updatedEntity)) + .then(() => { + this.unsavedChanges.pending = false; + return Object.assign(entity, updatedEntity); + }) .catch((err) => { throw new Error($localize`Could not save ${entity.getType()}\: ${err}`); }); @@ -162,5 +187,6 @@ export class EntityFormService { const newKeys = Object.keys(omit(form.controls, Object.keys(entity))); newKeys.forEach((key) => form.get(key).setValue(null)); form.markAsPristine(); + this.unsavedChanges.pending = false; } } diff --git a/src/app/core/entity-components/entity-subrecord/row-details/row-details.component.spec.ts b/src/app/core/entity-components/entity-subrecord/row-details/row-details.component.spec.ts index 7ef5169472..2993c63045 100644 --- a/src/app/core/entity-components/entity-subrecord/row-details/row-details.component.spec.ts +++ b/src/app/core/entity-components/entity-subrecord/row-details/row-details.component.spec.ts @@ -8,6 +8,7 @@ import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog"; import { Entity } from "../../../entity/model/entity"; import { MockedTestingModule } from "../../../../utils/mocked-testing.module"; import { EntityAbility } from "../../../permissions/ability/entity-ability"; +import { NEVER } from "rxjs"; describe("RowDetailsComponent", () => { let component: RowDetailsComponent; @@ -23,7 +24,10 @@ describe("RowDetailsComponent", () => { imports: [RowDetailsComponent, MockedTestingModule.withState()], providers: [ { provide: MAT_DIALOG_DATA, useValue: detailsComponentData }, - { provide: MatDialogRef, useValue: {} }, + { + provide: MatDialogRef, + useValue: { backdropClick: () => NEVER, afterClosed: () => NEVER }, + }, ], }).compileComponents(); spyOn(TestBed.inject(EntityAbility), "cannot").and.returnValue(true); diff --git a/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.spec.ts b/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.spec.ts index b28b828b0e..53108d14cb 100644 --- a/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.spec.ts +++ b/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.spec.ts @@ -19,15 +19,20 @@ import { EntityRemoveService, RemoveResult, } from "../../entity/entity-remove.service"; -import { of } from "rxjs"; +import { firstValueFrom, of, Subject } from "rxjs"; +import { UnsavedChangesService } from "../../entity-components/entity-details/form/unsaved-changes.service"; describe("DialogButtonsComponent", () => { let component: DialogButtonsComponent; let fixture: ComponentFixture; let dialogRef: jasmine.SpyObj>; + let backdropClick = new Subject(); + let closed = new Subject(); beforeEach(waitForAsync(() => { - dialogRef = jasmine.createSpyObj(["close"]); + dialogRef = jasmine.createSpyObj(["close", "backdropClick", "afterClosed"]); + dialogRef.backdropClick.and.returnValue(backdropClick); + dialogRef.afterClosed.and.returnValue(closed); TestBed.configureTestingModule({ imports: [DialogButtonsComponent, MockedTestingModule.withState()], providers: [{ provide: MatDialogRef, useValue: dialogRef }], @@ -100,4 +105,31 @@ describe("DialogButtonsComponent", () => { expect(dialogRef.close).toHaveBeenCalled(); }); + + it("should only close the dialog if user confirms to discard changes", fakeAsync(() => { + const confirmed = new Subject(); + spyOn( + TestBed.inject(UnsavedChangesService), + "checkUnsavedChanges" + ).and.returnValue(firstValueFrom(confirmed)); + + backdropClick.next(undefined); + tick(); + + expect(dialogRef.close).not.toHaveBeenCalled(); + + confirmed.next(true); + tick(); + + expect(dialogRef.close).toHaveBeenCalled(); + })); + + it("should reset pending changes when dialog is closed", () => { + const unsavedChanges = TestBed.inject(UnsavedChangesService); + unsavedChanges.pending = true; + + closed.next(); + + expect(unsavedChanges.pending).toBeFalse(); + }); }); diff --git a/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.ts b/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.ts index a7de09ff1d..44963421a5 100644 --- a/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.ts +++ b/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.ts @@ -17,6 +17,8 @@ import { MatMenuModule } from "@angular/material/menu"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { Router, RouterLink } from "@angular/router"; import { EntityAbility } from "../../permissions/ability/entity-ability"; +import { ConfirmationDialogService } from "../../confirmation-dialog/confirmation-dialog.service"; +import { UnsavedChangesService } from "../../entity-components/entity-details/form/unsaved-changes.service"; @Component({ selector: "app-dialog-buttons", @@ -45,8 +47,23 @@ export class DialogButtonsComponent implements OnInit { private alertService: AlertService, private entityRemoveService: EntityRemoveService, private router: Router, - private ability: EntityAbility - ) {} + private ability: EntityAbility, + private confirmation: ConfirmationDialogService, + private unsavedChanges: UnsavedChangesService + ) { + this.dialog.disableClose = true; + this.dialog.backdropClick().subscribe(() => + this.unsavedChanges.checkUnsavedChanges().then((confirmed) => { + if (confirmed) { + this.dialog.close(); + } + }) + ); + // This happens before the `canDeactivate` check and therefore does not warn when leaving + this.dialog + .afterClosed() + .subscribe(() => (this.unsavedChanges.pending = false)); + } ngOnInit() { if (!this.entity.isNew) { @@ -73,6 +90,7 @@ export class DialogButtonsComponent implements OnInit { .then((res) => { // Attachments are only saved once form is disabled this.form.disable(); + this.form.markAsPristine(); this.dialog.close(res); }) .catch((err) => { diff --git a/src/app/core/session/auth.guard.spec.ts b/src/app/core/session/auth.guard.spec.ts index f5bb5f3bdc..6b5a4165bc 100644 --- a/src/app/core/session/auth.guard.spec.ts +++ b/src/app/core/session/auth.guard.spec.ts @@ -2,43 +2,38 @@ import { TestBed } from "@angular/core/testing"; import { AuthGuard } from "./auth.guard"; import { SessionService } from "./session-service/session.service"; -import { Router } from "@angular/router"; +import { RouterTestingModule } from "@angular/router/testing"; describe("AuthGuard", () => { - let guard: AuthGuard; let mockSession: jasmine.SpyObj; - let mockRouter: jasmine.SpyObj; beforeEach(() => { mockSession = jasmine.createSpyObj(["isLoggedIn"]); - mockRouter = jasmine.createSpyObj(["navigate"]); TestBed.configureTestingModule({ - providers: [ - { provide: SessionService, useValue: mockSession }, - { provide: Router, useValue: mockRouter }, - ], + imports: [RouterTestingModule], + providers: [{ provide: SessionService, useValue: mockSession }], }); - guard = TestBed.inject(AuthGuard); }); it("should be created", () => { - expect(guard).toBeTruthy(); + expect(AuthGuard).toBeTruthy(); }); it("should return true if user is logged in", () => { mockSession.isLoggedIn.and.returnValue(true); - expect(guard.canActivate(undefined, undefined)).toBeTrue(); + const res = TestBed.runInInjectionContext(() => + AuthGuard(undefined, undefined) + ); + expect(res).toBeTrue(); }); it("should navigate to login page with redirect url if not logged in", () => { mockSession.isLoggedIn.and.returnValue(false); - expect( - guard.canActivate(undefined, { url: "/some/url" } as any) - ).toBeFalse(); - expect(mockRouter.navigate).toHaveBeenCalledWith(["/login"], { - queryParams: { redirect_uri: "/some/url" }, - }); + const res = TestBed.runInInjectionContext(() => + AuthGuard(undefined, { url: "/some/url" } as any) + ); + expect(res.toString()).toBe("/login?redirect_uri=%2Fsome%2Furl"); }); }); diff --git a/src/app/core/session/auth.guard.ts b/src/app/core/session/auth.guard.ts index 544f9ab130..d6ab2ea598 100644 --- a/src/app/core/session/auth.guard.ts +++ b/src/app/core/session/auth.guard.ts @@ -1,28 +1,23 @@ -import { Injectable } from "@angular/core"; +import { inject } from "@angular/core"; import { ActivatedRouteSnapshot, - CanActivate, + CanActivateFn, Router, RouterStateSnapshot, } from "@angular/router"; import { SessionService } from "./session-service/session.service"; -@Injectable({ - providedIn: "root", -}) -export class AuthGuard implements CanActivate { - constructor(private session: SessionService, private router: Router) {} - - canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) { - if (this.session.isLoggedIn()) { - return true; - } else { - this.router.navigate(["/login"], { - queryParams: { - redirect_uri: state.url, - }, - }); - return false; - } +export const AuthGuard: CanActivateFn = ( + route: ActivatedRouteSnapshot, + state: RouterStateSnapshot +) => { + if (inject(SessionService).isLoggedIn()) { + return true; + } else { + return inject(Router).createUrlTree(["/login"], { + queryParams: { + redirect_uri: state.url, + }, + }); } -} +}; diff --git a/src/app/core/view/dynamic-routing/router.service.spec.ts b/src/app/core/view/dynamic-routing/router.service.spec.ts index 32cc8e8ce6..6ae16054ab 100644 --- a/src/app/core/view/dynamic-routing/router.service.spec.ts +++ b/src/app/core/view/dynamic-routing/router.service.spec.ts @@ -65,18 +65,21 @@ describe("RouterService", () => { path: "child", loadComponent: componentRegistry.get("ChildrenList"), data: {}, + canDeactivate: [jasmine.any(Function)], canActivate: [AuthGuard], }, { path: "child/:id", loadComponent: componentRegistry.get("EntityDetails"), data: { config: testViewConfig }, + canDeactivate: [jasmine.any(Function)], canActivate: [AuthGuard], }, { path: "admin", loadComponent: componentRegistry.get("Admin"), canActivate: [AuthGuard, UserRoleGuard], + canDeactivate: [jasmine.any(Function)], data: { permittedUserRoles: ["user_app"] }, }, ]; @@ -106,6 +109,7 @@ describe("RouterService", () => { path: "other", component: TestComponent, canActivate: [AuthGuard, UserRoleGuard], + canDeactivate: [jasmine.any(Function)], data: { permittedUserRoles: ["admin_app"] }, }, { path: "child", component: ChildrenListComponent }, @@ -156,6 +160,7 @@ describe("RouterService", () => { path: "admin", loadComponent: componentRegistry.get("Admin"), canActivate: [AuthGuard, UserRoleGuard], + canDeactivate: [jasmine.any(Function)], data: { permittedUserRoles: ["admin"] }, }, ]; diff --git a/src/app/core/view/dynamic-routing/router.service.ts b/src/app/core/view/dynamic-routing/router.service.ts index b05bcebad0..70ef26ae6b 100644 --- a/src/app/core/view/dynamic-routing/router.service.ts +++ b/src/app/core/view/dynamic-routing/router.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@angular/core"; +import { inject, Injectable } from "@angular/core"; import { Route, Router } from "@angular/router"; import { ConfigService } from "../../config/config.service"; import { LoggingService } from "../../logging/logging.service"; @@ -11,6 +11,7 @@ import { UserRoleGuard } from "../../permissions/permission-guard/user-role.guar import { NotFoundComponent } from "./not-found/not-found.component"; import { ComponentRegistry } from "../../../dynamic-components"; import { AuthGuard } from "../../session/auth.guard"; +import { UnsavedChangesService } from "../../entity-components/entity-details/form/unsaved-changes.service"; /** * The RouterService dynamically sets up Angular routing from config loaded through the {@link ConfigService}. @@ -90,6 +91,9 @@ export class RouterService { private generateRouteFromConfig(view: ViewConfig, route: Route): Route { const routeData: RouteData = {}; route.canActivate = [AuthGuard]; + route.canDeactivate = [ + () => inject(UnsavedChangesService).checkUnsavedChanges(), + ]; if (view.permittedUserRoles) { route.canActivate.push(UserRoleGuard); diff --git a/src/app/features/todos/todo-details/todo-details.component.spec.ts b/src/app/features/todos/todo-details/todo-details.component.spec.ts index 8751208824..18a57bf978 100644 --- a/src/app/features/todos/todo-details/todo-details.component.spec.ts +++ b/src/app/features/todos/todo-details/todo-details.component.spec.ts @@ -8,6 +8,7 @@ import { TodoService } from "../todo.service"; import { MockedTestingModule } from "../../../utils/mocked-testing.module"; import { LoginState } from "../../../core/session/session-states/login-state.enum"; import { EntityMapperService } from "../../../core/entity/entity-mapper.service"; +import { NEVER } from "rxjs"; describe("TodoDetailsComponent", () => { let component: TodoDetailsComponent; @@ -25,7 +26,14 @@ describe("TodoDetailsComponent", () => { provide: MAT_DIALOG_DATA, useValue: { entity: new Todo(), columns: [] }, }, - { provide: MatDialogRef, useValue: jasmine.createSpyObj(["close"]) }, + { + provide: MatDialogRef, + useValue: { + close: () => {}, + backdropClick: () => NEVER, + afterClosed: () => NEVER, + }, + }, TodoService, ], }).compileComponents(); diff --git a/src/app/features/todos/todo-details/todo-details.stories.ts b/src/app/features/todos/todo-details/todo-details.stories.ts index d255fbbb16..ea4250a897 100644 --- a/src/app/features/todos/todo-details/todo-details.stories.ts +++ b/src/app/features/todos/todo-details/todo-details.stories.ts @@ -9,6 +9,7 @@ import { Todo } from "../model/todo"; import { EntityMapperService } from "../../../core/entity/entity-mapper.service"; import { mockEntityMapper } from "../../../core/entity/mock-entity-mapper-service"; import { FormFieldConfig } from "../../../core/entity-components/entity-form/entity-form/FormConfig"; +import { NEVER } from "rxjs"; const defaultColumns: FormFieldConfig[] = [ { id: "deadline" }, @@ -34,7 +35,10 @@ export default { provide: MAT_DIALOG_DATA, useValue: { entity: todoEntity, columns: defaultColumns }, }, - { provide: MatDialogRef, useValue: {} }, + { + provide: MatDialogRef, + useValue: { backdropClick: () => NEVER, afterClosed: () => NEVER }, + }, { provide: TodoService, useValue: {},