diff --git a/package.json b/package.json index 51863c3260..621def07c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hmcts/ccd-case-ui-toolkit", - "version": "7.3.0", + "version": "7.2.54-3455-rc1", "engines": { "node": ">=20.19.0" }, diff --git a/projects/ccd-case-ui-toolkit/package.json b/projects/ccd-case-ui-toolkit/package.json index 21cc23514e..bb2835966b 100644 --- a/projects/ccd-case-ui-toolkit/package.json +++ b/projects/ccd-case-ui-toolkit/package.json @@ -1,6 +1,6 @@ { "name": "@hmcts/ccd-case-ui-toolkit", - "version": "7.3.0", + "version": "7.2.54-3455-rc1", "engines": { "node": ">=20.19.0" }, diff --git a/projects/ccd-case-ui-toolkit/src/lib/shared/components/case-editor/case-edit/case-edit.component.spec.ts b/projects/ccd-case-ui-toolkit/src/lib/shared/components/case-editor/case-edit/case-edit.component.spec.ts index b65ff2961c..4fce7a5aa7 100644 --- a/projects/ccd-case-ui-toolkit/src/lib/shared/components/case-editor/case-edit/case-edit.component.spec.ts +++ b/projects/ccd-case-ui-toolkit/src/lib/shared/components/case-editor/case-edit/case-edit.component.spec.ts @@ -1,10 +1,10 @@ import { DebugElement } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { FormArray, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { FormArray, FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute, NavigationStart, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { MockComponent } from 'ng2-mock-component'; -import { Observable, of, throwError } from 'rxjs'; +import { Observable, of, Subject, throwError } from 'rxjs'; import { ConditionalShowRegistrarService } from '../../../directives'; import { CaseView, FieldType, HttpError, Profile } from '../../../domain'; import { CaseEventTrigger } from '../../../domain/case-view/case-event-trigger.model'; @@ -879,20 +879,44 @@ describe('CaseEditComponent', () => { }); }); - it('should check page is not refreshed', () => { + it('should check page is not refreshed', async () => { mockSessionStorageService.getItem.and.returnValue(component.initialUrl = null); mockSessionStorageService.getItem.and.returnValue(component.isPageRefreshed = false); - + routerStub.url = 'test.com'; fixture.detectChanges(); - expect(component.checkPageRefresh()).toBe(false); + const result = await component.checkPageRefresh(); + expect(result).toBe(false); + }); + + it('should redirect to first wizard page when user navigates directly to submit without initialUrl', async () => { + routerStub.url = '/some/case/path/submit'; + component.initialUrl = null; + component.isPageRefreshed = false; + component.eventTrigger = { + wizard_pages: [ + { id: 'secondPage', order: 2 }, + { id: 'firstPage', order: 1 } + ] + } as any; + + spyOn((component as any).windowsService, 'alert'); + (routerStub.navigate as jasmine.Spy).and.returnValue(Promise.resolve(true)); + + const result = await component.checkPageRefresh(); + + expect(result).toBeFalsy(); + expect((component as any).windowsService.alert).toHaveBeenCalledWith(CaseEditComponent.ALERT_MESSAGE); + expect(routerStub.navigate).toHaveBeenCalledWith(['firstPage'], { relativeTo: (component as any).route }); }); - it('should check page is refreshed', () => { + it('should check page is refreshed', async () => { mockSessionStorageService.getItem.and.returnValue(component.initialUrl = 'test'); mockSessionStorageService.getItem.and.returnValue(component.isPageRefreshed = true); fixture.detectChanges(); - expect(component.checkPageRefresh()).toBe(true); + const result = await component.checkPageRefresh(); + + expect(result).toBe(true); }); }); @@ -1605,6 +1629,300 @@ describe('CaseEditComponent', () => { expect(component.taskExistsForThisEvent(mockTask as Task, mockTaskEventCompletionInfo, mockEventDetails)).toBe(true); }); }); + + describe('error handling', () => { + it('should handle submit errors gracefully', () => { + const mockClass = { + submit: () => throwError({ status: 500, message: 'Server error' }) + }; + + formValueService.sanitise.and.returnValue({ name: 'test' }); + + component.submitForm({ + eventTrigger: component.eventTrigger, + caseDetails: component.caseDetails, + form: component.form, + submit: mockClass.submit, + }); + + expect(component.isSubmitting).toBe(false); + }); + }); + + describe('monitorBackButtonDuringRefresh', () => { + it('should not navigate when isPageRefreshed is false', () => { + // Override session storage responses for this test: initialUrl + isPageRefreshed + mockSessionStorageService.getItem.and.returnValues('example url', 'false'); + // Re-run ngOnInit logic manually if needed + component.isPageRefreshed = false; + routerStub.navigate.calls.reset(); // clear any calls made during first detectChanges + + component.eventTrigger = { + wizard_pages: [ + { id: 'firstPage', order: 1 }, + { id: 'secondPage', order: 2 } + ] + } as any; + + (component as any).monitorBackButtonDuringRefresh(); + + expect(routerStub.navigate).not.toHaveBeenCalled(); + }); + + it('should handle missing wizard pages gracefully', () => { + component.isPageRefreshed = true; + component.eventTrigger = { + wizard_pages: [] + } as any; + + (component as any).monitorBackButtonDuringRefresh(); + + expect(routerStub.navigate).not.toHaveBeenCalled(); + }); + + it('should handle undefined wizard pages', () => { + component.isPageRefreshed = true; + component.eventTrigger = {} as any; + + (component as any).monitorBackButtonDuringRefresh(); + + expect(routerStub.navigate).not.toHaveBeenCalled(); + }); + + it('should not navigate if eventTrigger is null', () => { + component.isPageRefreshed = true; + component.eventTrigger = null; + routerStub.navigate.calls.reset(); + + (component as any).monitorBackButtonDuringRefresh(); + + expect(routerStub.navigate).not.toHaveBeenCalled(); + }); + }); + }); + + describe('CaseEditComponent private field handlers', () => { + let component: CaseEditComponent; + + // Minimal stubs for required constructor deps + const stub = {} as any; + const appConfigStub = { logMessage: () => {} } as any; + + beforeEach(() => { + component = new CaseEditComponent( + new FormBuilder(), + stub, // CaseNotifier + { events: new Subject(), navigate: () => Promise.resolve(true), navigateByUrl: () => Promise.resolve(true) } as any, + { queryParams: of({}) } as any, + stub, // FieldsUtils + stub, // FieldsPurger + stub, // ConditionalShowRegistrarService + { create: () => ({}) } as any, // WizardFactoryService + { getItem: () => null } as any, // SessionStorageService + { alert: () => Promise.resolve() } as any, // WindowService + { sanitise: (d: any) => d, clearNonCaseFields: () => {}, removeNullLabels: () => {}, removeEmptyDocuments: () => {}, + removeEmptyCollectionsWithMinValidation: () => {}, repopulateFormDataFromCaseFieldValues: () => {}, + populateLinkedCasesDetailsFromCaseFields: () => {}, removeCaseFieldsOfType: () => {} } as any, + { mapFieldErrors: () => {} } as any, // FormErrorService + { register: () => 't', unregister: () => {} } as any, // LoadingService + { deleteNonValidatedFields: () => {}, validPageListCaseFields: () => [] } as any, // ValidPageListCaseFieldsService + { assignAndCompleteTask: () => of(true), completeTask: () => of(true) } as any, // WorkAllocationService + { error: () => {}, setPreserveAlerts: () => {} } as any, // AlertService + appConfigStub, // AbstractAppConfig + stub // ReadCookieService + ); + }); + + describe('handleNonComplexField', () => { + it('should use parentField.formatted_value when parent has formatted_value', () => { + const parentField: CaseField = { + id: 'parent', + formatted_value: { child: 'originalFromParent' } + } as any; + const caseField: CaseField = { + id: 'child', + formatted_value: 'shouldNotUseDirect' + } as any; + const rawFormValueData: any = {}; + + (component as any).handleNonComplexField(parentField, rawFormValueData, 'child', caseField); + + expect(rawFormValueData['child']).toBe('originalFromParent'); + }); + + it('should not overwrite when field is hidden and retain_hidden_value is true', () => { + const caseField: CaseField = { + id: 'child', + hidden: true, + retain_hidden_value: true, + formatted_value: 'originalValue' + } as any; + const rawFormValueData: any = { child: 'existingValue' }; + + (component as any).handleNonComplexField(null, rawFormValueData, 'child', caseField); + + expect(rawFormValueData['child']).toBe('existingValue'); + }); + + it('should set formatted_value when no parent and not (hidden && retain_hidden_value)', () => { + const caseField: CaseField = { + id: 'child', + hidden: false, + retain_hidden_value: true, // hidden is false so condition fails + formatted_value: 'directFormatted' + } as any; + const rawFormValueData: any = {}; + + (component as any).handleNonComplexField(null, rawFormValueData, 'child', caseField); + + expect(rawFormValueData['child']).toBe('directFormatted'); + }); + }); + + describe('handleComplexField', () => { + it('should call replaceHiddenFormValuesWithOriginalCaseData and assign result when value is not null', () => { + const subField: CaseField = { + id: 'sub1', + hidden: true, + retain_hidden_value: true, + formatted_value: 'sub1Original', + field_type: { type: 'Text' } as any + } as any; + + const complexField: CaseField = { + id: 'complex1', + value: {}, // triggers branch + field_type: { type: 'Complex', complex_fields: [subField] } as any + } as any; + + const formGroup = new FormGroup({ + complex1: new FormControl({ sub1: 'editedValue' }) + }); + + const rawFormValueData: any = { complex1: { sub1: 'editedValue' } }; + + const spy = spyOn(component as any, 'replaceHiddenFormValuesWithOriginalCaseData') + .and.returnValue({ sub1: 'replacedValue' }); + + (component as any).handleComplexField(complexField, formGroup, 'complex1', rawFormValueData); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(jasmine.any(FormGroup), [subField], complexField); + expect(rawFormValueData['complex1']).toEqual({ sub1: 'replacedValue' }); + }); + + it('should not modify rawFormValueData when complex field value is null', () => { + const complexField: CaseField = { + id: 'complex1', + value: null, + field_type: { type: 'Complex', complex_fields: [] } as any + } as any; + + const formGroup = new FormGroup({ + complex1: new FormControl({ sub1: 'editedValue' }) + }); + + const rawFormValueData: any = { complex1: { sub1: 'editedValue' } }; + + const spy = spyOn(component as any, 'replaceHiddenFormValuesWithOriginalCaseData'); + + (component as any).handleComplexField(complexField, formGroup, 'complex1', rawFormValueData); + + expect(spy).not.toHaveBeenCalled(); + expect(rawFormValueData['complex1']).toEqual({ sub1: 'editedValue' }); + }); + }); + }); + + // Helper to build component with minimal deps + function buildComponent(routerStub: any): CaseEditComponent { + const stub = {} as any; + return new CaseEditComponent( + new FormBuilder(), + stub, // CaseNotifier + routerStub, // Router + { queryParams: of({}) } as any, // ActivatedRoute + stub, // FieldsUtils + stub, // FieldsPurger + stub, // ConditionalShowRegistrarService + { create: () => ({ firstPage: () => null, getPage: () => null }) } as any, // WizardFactoryService + { getItem: () => null, removeItem: () => {} } as any, // SessionStorageService + { alert: () => Promise.resolve() } as any, // WindowService + { sanitise: (d: any) => d, clearNonCaseFields: () => {}, removeNullLabels: () => {}, removeEmptyDocuments: () => {}, + removeEmptyCollectionsWithMinValidation: () => {}, repopulateFormDataFromCaseFieldValues: () => {}, + populateLinkedCasesDetailsFromCaseFields: () => {}, removeCaseFieldsOfType: () => {}, removeUnnecessaryFields: () => {} } as any, + { mapFieldErrors: () => {} } as any, // FormErrorService + { register: () => 'token', unregister: () => {} } as any, // LoadingService + { deleteNonValidatedFields: () => {}, validPageListCaseFields: () => [] } as any, // ValidPageListCaseFieldsService + { assignAndCompleteTask: () => of(true), completeTask: () => of(true) } as any, // WorkAllocationService + { error: () => {}, setPreserveAlerts: () => {} } as any, // AlertService + { logMessage: () => {} } as any, // AbstractAppConfig + stub // ReadCookieService + ); + } + + describe('CaseEditComponent monitorBackButtonDuringRefresh', () => { + it('should return early when router.events is undefined', () => { + const routerStub = { navigateByUrl: () => Promise.resolve(true) }; // no events + const component = buildComponent(routerStub); + (component as any).monitorBackButtonDuringRefresh(); + expect((component as any).backSubscription).toBeUndefined(); + }); + + it('should subscribe and set backButtonDuringRefresh=true on popstate when page refreshed and not acknowledged', () => { + const events$ = new Subject(); + const routerStub = { + events: events$, + navigateByUrl: () => Promise.resolve(true) + }; + const component = buildComponent(routerStub); + component.isPageRefreshed = true; + component.pageRefreshAcknowledged = false; + + (component as any).monitorBackButtonDuringRefresh(); + expect((component as any).backSubscription).toBeDefined(); + + expect(component.backButtonDuringRefresh).toBeFalsy(); + events$.next(new NavigationStart(1, '/prev', 'popstate')); + expect(component.backButtonDuringRefresh).toBe(true); + }); + + it('should not set flag when page already acknowledged', () => { + const events$ = new Subject(); + const routerStub = { events: events$, navigateByUrl: () => Promise.resolve(true) }; + const component = buildComponent(routerStub); + component.isPageRefreshed = true; + component.pageRefreshAcknowledged = true; + + (component as any).monitorBackButtonDuringRefresh(); + events$.next(new NavigationStart(2, '/prev', 'popstate')); + expect(component.backButtonDuringRefresh).toBeFalsy(); + }); + + it('should ignore non-popstate navigation triggers', () => { + const events$ = new Subject(); + const routerStub = { events: events$, navigateByUrl: () => Promise.resolve(true) }; + const component = buildComponent(routerStub); + component.isPageRefreshed = true; + component.pageRefreshAcknowledged = false; + + (component as any).monitorBackButtonDuringRefresh(); + events$.next(new NavigationStart(3, '/prev', 'imperative')); + expect(component.backButtonDuringRefresh).toBeFalsy(); + }); + + it('should unsubscribe on ngOnDestroy', () => { + const events$ = new Subject(); + const routerStub = { events: events$, navigateByUrl: () => Promise.resolve(true) }; + const component = buildComponent(routerStub); + component.isPageRefreshed = true; + (component as any).monitorBackButtonDuringRefresh(); + const sub = (component as any).backSubscription; + spyOn(sub, 'unsubscribe').and.callThrough(); + + component.ngOnDestroy(); + expect(sub.unsubscribe).toHaveBeenCalled(); + }); }); xdescribe('profile not available in route', () => { diff --git a/projects/ccd-case-ui-toolkit/src/lib/shared/components/case-editor/case-edit/case-edit.component.ts b/projects/ccd-case-ui-toolkit/src/lib/shared/components/case-editor/case-edit/case-edit.component.ts index 770f60c85e..047a3b7b72 100644 --- a/projects/ccd-case-ui-toolkit/src/lib/shared/components/case-editor/case-edit/case-edit.component.ts +++ b/projects/ccd-case-ui-toolkit/src/lib/shared/components/case-editor/case-edit/case-edit.component.ts @@ -1,8 +1,8 @@ import { Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; import { FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; -import { ActivatedRoute, Params, Router } from '@angular/router'; +import { ActivatedRoute, NavigationStart, Params, Router } from '@angular/router'; import { Observable, Subject, of } from 'rxjs'; -import { finalize, switchMap } from 'rxjs/operators'; +import { filter, finalize, switchMap } from 'rxjs/operators'; import { AbstractAppConfig } from '../../../../app.config'; import { Constants } from '../../../commons/constants'; @@ -94,6 +94,12 @@ export class CaseEditComponent implements OnInit, OnDestroy { public validPageList: WizardPage[] = []; + public pageRefreshAcknowledged = false; + + public backButtonDuringRefresh = false; + + private backSubscription: any; + constructor( private readonly fb: FormBuilder, private readonly caseNotifier: CaseNotifier, @@ -120,7 +126,9 @@ export class CaseEditComponent implements OnInit, OnDestroy { this.initialUrl = this.sessionStorageService.getItem('eventUrl'); this.isPageRefreshed = JSON.parse(this.sessionStorageService.getItem('isPageRefreshed')); - this.checkPageRefresh(); + // check whether user selected back button instead of ok button on the browser alert on page refresh + this.monitorBackButtonDuringRefresh(); + void this.checkPageRefresh(); this.form = this.fb.group({ data: new FormGroup({}), @@ -141,15 +149,51 @@ export class CaseEditComponent implements OnInit, OnDestroy { if (this.callbackErrorsSubject) { this.callbackErrorsSubject.unsubscribe(); } + this.backSubscription?.unsubscribe(); } - public checkPageRefresh(): boolean { + private monitorBackButtonDuringRefresh(): void { + if (!this.router?.events) { + return; + } + this.backSubscription = this.router.events + .pipe(filter(e => e instanceof NavigationStart && (e as NavigationStart).navigationTrigger === 'popstate')) + .subscribe(() => { + if (this.isPageRefreshed && !this.pageRefreshAcknowledged) { + this.backButtonDuringRefresh = true; + } + }); + } + + public async checkPageRefresh(): Promise { + const targetUrl = this.initialUrl; // keep before removal + this.pageRefreshAcknowledged = false; if (this.isPageRefreshed && this.initialUrl) { this.sessionStorageService.removeItem('eventUrl'); - this.windowsService.alert(CaseEditComponent.ALERT_MESSAGE); - this.router.navigate([this.initialUrl], { relativeTo: this.route }); + // Optional: prevent user from navigating back before OK + const blockPopstate = (ev: PopStateEvent) => { + // force forward to target + this.router.navigateByUrl(targetUrl, { replaceUrl: true }); + } + window.addEventListener('popstate', blockPopstate, { once: true }); + await this.windowsService.alert(CaseEditComponent.ALERT_MESSAGE); + this.pageRefreshAcknowledged = true; + this.backButtonDuringRefresh = false; + + await this.router.navigateByUrl(targetUrl, { replaceUrl: true }); + window.removeEventListener('popstate', blockPopstate); return true; } + // if the url contains /submit there is the potential that the user has gone straight to the submit page + // we should try and work out if they have been through the journey or not and prevent them submitting directly + if (this.router.url.indexOf('/submit') !== -1 && !this.initialUrl) { + // we only want to check if the user has done this if there is a multi-page journey + if (this.eventTrigger.wizard_pages && this.eventTrigger.wizard_pages.length > 0) { + const firstPage = this.eventTrigger.wizard_pages.reduce((min, page) => page.order < min.order ? page : min, this.eventTrigger.wizard_pages[0]); + await this.windowsService.alert(CaseEditComponent.ALERT_MESSAGE); + await this.router.navigate([firstPage ? firstPage.id : 'submit'], { relativeTo: this.route }); + } + } return false; } @@ -260,7 +304,7 @@ export class CaseEditComponent implements OnInit, OnDestroy { } const eventId = this.getEventId(form); const caseId = this.getCaseId(caseDetails); - const userId = userInfo.id ? userInfo.id : userInfo.uid; + const userId = userInfo?.id ? userInfo?.id : userInfo?.uid; const eventDetails: EventDetails = {eventId, caseId, userId, assignNeeded}; if (this.taskExistsForThisEvent(taskInSessionStorage, taskEventCompletionInfo, eventDetails)) { this.abstractConfig.logMessage(`task exist for this event for caseId and eventId as ${caseId} ${eventId}`); diff --git a/projects/ccd-case-ui-toolkit/src/lib/shared/services/window/window.service.ts b/projects/ccd-case-ui-toolkit/src/lib/shared/services/window/window.service.ts index 00db0fa083..2bdac9d545 100644 --- a/projects/ccd-case-ui-toolkit/src/lib/shared/services/window/window.service.ts +++ b/projects/ccd-case-ui-toolkit/src/lib/shared/services/window/window.service.ts @@ -38,7 +38,8 @@ export class WindowService { return window.confirm(message); } - public alert(message: string): void { - return window.alert(message); + public alert(message: string): Promise { + window.alert(message); + return Promise.resolve(); } }