diff --git a/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.spec.ts b/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.spec.ts index 5e2f295302..58722ad0c8 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.spec.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.spec.ts @@ -229,6 +229,21 @@ describe('FinesMacFixedPenaltyFormComponent', () => { vi.useRealTimers(); }); + it('should set offenceCodeValidationPending immediately when lookup-length offence code changes', () => { + vi.useFakeTimers(); + component['setupFixedPenaltyDetailsForm'](); + component['setupOffenceCodeListener'](); + component.form.get('fm_fp_offence_details_offence_id')?.setValue(314441); + + component.form.get('fm_fp_offence_details_offence_cjs_code')?.setValue('AK12345'); + + expect(component.form.get('fm_fp_offence_details_offence_id')?.value).toBeNull(); + expect(component.form.get('fm_fp_offence_details_offence_cjs_code')?.errors).toEqual({ + offenceCodeValidationPending: true, + }); + vi.useRealTimers(); + }); + it('should set initial value if dob value already exists', () => { component['setupFixedPenaltyDetailsForm'](); component.form.get('fm_fp_personal_details_dob')?.setValue('01-01-1979'); diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/constants/fines-mac-offence-details-offences-field-errors.constant.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/constants/fines-mac-offence-details-offences-field-errors.constant.ts index f6c607a3b7..9ab2423a9d 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/constants/fines-mac-offence-details-offences-field-errors.constant.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/constants/fines-mac-offence-details-offences-field-errors.constant.ts @@ -36,9 +36,17 @@ export const FINES_MAC_OFFENCE_DETAILS_OFFENCES_FIELD_ERRORS: IAbstractFormBaseF message: 'Offence code must be 7 or 8 characters', priority: 4, }, + offenceCodeLookupFailed: { + message: 'We could not validate the offence code. Try again', + priority: 5, + }, + offenceCodeValidationPending: { + message: 'Wait for offence code validation to complete', + priority: 6, + }, invalidOffenceCode: { message: 'Offence not found', - priority: 5, + priority: 7, }, }, }; diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.spec.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.spec.ts index c5c7be5e88..a80df4f25d 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.spec.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.spec.ts @@ -73,6 +73,7 @@ describe('FinesMacOffenceDetailsAddAnOffenceFormComponent', () => { 'upperCaseFirstLetter', 'scrollToTop', ]); + mockUtilsService.upperCaseAllLetters.mockImplementation((value: string) => value?.toUpperCase?.() ?? value); await TestBed.configureTestingModule({ imports: [FinesMacOffenceDetailsAddAnOffenceFormComponent], @@ -729,6 +730,115 @@ describe('FinesMacOffenceDetailsAddAnOffenceFormComponent', () => { expect(superHandleFormSubmitSpy).toHaveBeenCalledWith(event); }); + it('should set offenceCodeValidationPending on submit when offence code length is valid and offence id is unresolved', () => { + const offenceCodeControl = component.form.controls['fm_offence_details_offence_cjs_code']; + const offenceIdControl = component.form.controls['fm_offence_details_offence_id']; + + offenceCodeControl.setValue('AK12345'); + offenceCodeControl.setErrors(null); + offenceIdControl.setValue(null); + + component.handleFormSubmit(new SubmitEvent('submit')); + + expect(offenceCodeControl.errors).toEqual(expect.objectContaining({ offenceCodeValidationPending: true })); + }); + + it('should preserve existing offence code errors when setting offenceCodeValidationPending', () => { + const offenceCodeControl = component.form.controls['fm_offence_details_offence_cjs_code']; + const offenceIdControl = component.form.controls['fm_offence_details_offence_id']; + + offenceCodeControl.setValue('AK12345'); + offenceCodeControl.setErrors({ customError: true }); + offenceIdControl.setValue(null); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component as any).enforceOffenceCodeValidationBeforeSubmit(); + + expect(offenceCodeControl.errors).toEqual({ + customError: true, + offenceCodeValidationPending: true, + }); + }); + + it('should handle null offence code values without setting pending validation', () => { + const offenceCodeControl = component.form.controls['fm_offence_details_offence_cjs_code']; + const offenceIdControl = component.form.controls['fm_offence_details_offence_id']; + + offenceCodeControl.setValue(null); + offenceCodeControl.setErrors({ offenceCodeValidationPending: true }); + offenceIdControl.setValue(null); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component as any).enforceOffenceCodeValidationBeforeSubmit(); + + expect(offenceCodeControl.errors).toBeNull(); + }); + + it('should not set offenceCodeValidationPending on submit when offence code is already invalid', () => { + const offenceCodeControl = component.form.controls['fm_offence_details_offence_cjs_code']; + const offenceIdControl = component.form.controls['fm_offence_details_offence_id']; + + offenceCodeControl.setValue('AK12345'); + offenceCodeControl.setErrors({ invalidOffenceCode: true }); + offenceIdControl.setValue(null); + + component.handleFormSubmit(new SubmitEvent('submit')); + + expect(offenceCodeControl.errors).toEqual({ invalidOffenceCode: true }); + expect(offenceCodeControl.errors?.['offenceCodeValidationPending']).toBeUndefined(); + }); + + it('should not set offenceCodeValidationPending on submit when offence lookup failed', () => { + const offenceCodeControl = component.form.controls['fm_offence_details_offence_cjs_code']; + const offenceIdControl = component.form.controls['fm_offence_details_offence_id']; + + offenceCodeControl.setValue('AK12345'); + offenceCodeControl.setErrors({ offenceCodeLookupFailed: true }); + offenceIdControl.setValue(null); + + component.handleFormSubmit(new SubmitEvent('submit')); + + expect(offenceCodeControl.errors).toEqual({ offenceCodeLookupFailed: true }); + expect(offenceCodeControl.errors?.['offenceCodeValidationPending']).toBeUndefined(); + }); + + it('should remove offenceCodeValidationPending and keep other existing errors', () => { + const offenceCodeControl = component.form.controls['fm_offence_details_offence_cjs_code']; + const offenceIdControl = component.form.controls['fm_offence_details_offence_id']; + + offenceCodeControl.setValue('AK12345'); + offenceCodeControl.setErrors({ + offenceCodeValidationPending: true, + invalidOffenceCode: true, + }); + offenceIdControl.setValue(null); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (component as any).enforceOffenceCodeValidationBeforeSubmit(); + + expect(offenceCodeControl.errors).toEqual({ invalidOffenceCode: true }); + }); + + it('should clear offenceCodeValidationPending on submit when offence id is set', () => { + const offenceCodeControl = component.form.controls['fm_offence_details_offence_cjs_code']; + const offenceIdControl = component.form.controls['fm_offence_details_offence_id']; + + offenceCodeControl.setValue('AK12345'); + offenceCodeControl.setErrors({ offenceCodeValidationPending: true }); + offenceIdControl.setValue(314441); + + component.handleFormSubmit(new SubmitEvent('submit')); + + expect(offenceCodeControl.errors?.['offenceCodeValidationPending']).toBeUndefined(); + }); + + it('should return early when offence code or offence id controls are missing', () => { + component.form.removeControl('fm_offence_details_offence_id'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => (component as any).enforceOffenceCodeValidationBeforeSubmit()).not.toThrow(); + }); + it('should add a new draft offence when index is -1', () => { component.form.controls['fm_offence_details_id'].setValue('test-id'); // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.ts index 34273998db..66cfc1501f 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.ts @@ -480,6 +480,42 @@ export class FinesMacOffenceDetailsAddAnOffenceFormComponent } } + /** + * Ensures the offence code lookup has completed before allowing submission. + * If a 7 or 8 character code has no resolved offence id yet, a pending-validation + * error is applied so the form remains invalid and the user sees a clear message. + */ + private enforceOffenceCodeValidationBeforeSubmit(): void { + const offenceCodeControl = this.form.get('fm_offence_details_offence_cjs_code') as FormControl | null; + const offenceIdControl = this.form.get('fm_offence_details_offence_id') as FormControl | null; + + if (!offenceCodeControl || !offenceIdControl) { + return; + } + + const offenceCode = offenceCodeControl.value ?? ''; + const isLookupLength = offenceCode.length >= 7 && offenceCode.length <= 8; + const hasOffenceId = offenceIdControl.value !== null && offenceIdControl.value !== undefined; + const hasInvalidOffenceCodeError = Boolean(offenceCodeControl.errors?.['invalidOffenceCode']); + const hasOffenceCodeLookupFailedError = Boolean(offenceCodeControl.errors?.['offenceCodeLookupFailed']); + + if (isLookupLength && !hasOffenceId && !hasInvalidOffenceCodeError && !hasOffenceCodeLookupFailedError) { + const currentErrors = offenceCodeControl.errors; + const updatedErrors = currentErrors + ? { ...currentErrors, offenceCodeValidationPending: true } + : { offenceCodeValidationPending: true }; + offenceCodeControl.setErrors(updatedErrors, { emitEvent: false }); + return; + } + + const currentErrors = offenceCodeControl.errors; + if (currentErrors?.['offenceCodeValidationPending']) { + const remainingErrors = { ...currentErrors }; + delete remainingErrors['offenceCodeValidationPending']; + offenceCodeControl.setErrors(Object.keys(remainingErrors).length ? remainingErrors : null, { emitEvent: false }); + } + } + /** * Navigates to the minor creditor page for the specified row index. * @@ -610,6 +646,7 @@ export class FinesMacOffenceDetailsAddAnOffenceFormComponent */ public override handleFormSubmit(event: SubmitEvent): void { this.checkImpositionMinorCreditors(); + this.enforceOffenceCodeValidationBeforeSubmit(); super.handleFormSubmit(event); } diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.spec.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.spec.ts index ce71eb0d95..786c63ef3e 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.spec.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.spec.ts @@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { FinesMacOffenceDetailsService } from './fines-mac-offence-details.service'; import { FINES_MAC_OFFENCE_DETAILS_FORM_MOCK } from '../mocks/fines-mac-offence-details-form.mock'; import { FormControl, FormGroup } from '@angular/forms'; -import { Observable, of, Subject } from 'rxjs'; +import { Observable, of, Subject, throwError } from 'rxjs'; import { FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES } from '../constants/fines-mac-offence-details-default-values.constant'; import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface'; import { provideHttpClient } from '@angular/common/http'; @@ -100,6 +100,31 @@ describe('FinesMacOffenceDetailsService', () => { expect(result[0].formData.fm_offence_details_impositions[0]).toEqual(expected); }); + it('setControlError - should remove one error key and keep remaining errors', () => { + const control = new FormControl('code'); + control.setErrors({ + invalidOffenceCode: true, + customError: true, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).setControlError(control, 'invalidOffenceCode', false); + + expect(control.errors).toEqual({ customError: true }); + }); + + it('setControlError - should clear all errors when removing the final error key', () => { + const control = new FormControl('code'); + control.setErrors({ + invalidOffenceCode: true, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (service as any).setControlError(control, 'invalidOffenceCode', false); + + expect(control.errors).toBeNull(); + }); + describe('initOffenceListener', () => { let form: FormGroup; let destroy$: Subject; @@ -178,6 +203,93 @@ describe('FinesMacOffenceDetailsService', () => { expect(onConfirmChangeSpy).toHaveBeenCalledWith(true); }); + it('should clear offence id and set confirmation to false immediately when code changes', () => { + vi.useFakeTimers(); + form.get('id')?.setValue(314441); + + service.initOffenceCodeListener( + form, + 'code', + 'id', + destroy$, + getOffenceByCjsCode, + onResultSpy, + onConfirmChangeSpy, + ); + + form.get('code')?.setValue('xy98765'); + + expect(form.get('id')?.value).toBeNull(); + expect(form.get('code')?.errors).toEqual({ offenceCodeValidationPending: true }); + expect(onConfirmChangeSpy).toHaveBeenCalledWith(false); + }); + + it('should not set pending validation error immediately for short codes', () => { + vi.useFakeTimers(); + form.get('id')?.setValue(314441); + + service.initOffenceCodeListener( + form, + 'code', + 'id', + destroy$, + getOffenceByCjsCode, + onResultSpy, + onConfirmChangeSpy, + ); + + form.get('code')?.setValue('xy98'); + + expect(form.get('id')?.value).toBeNull(); + expect(form.get('code')?.errors).toBeNull(); + expect(onConfirmChangeSpy).toHaveBeenCalledWith(false); + }); + + it('should ignore stale offence lookup responses when code changes quickly', () => { + vi.useFakeTimers(); + + const firstLookup$ = new Subject(); + const secondLookup$ = new Subject(); + getOffenceByCjsCode = vi.fn((code: string) => { + return code === 'AB12345' ? firstLookup$.asObservable() : secondLookup$.asObservable(); + }); + + const staleResponse: IOpalFinesOffencesRefData = { + ...offenceMockResponse, + refData: [{ ...offenceMockResponse.refData[0], offence_id: 111111, get_cjs_code: 'AB12345' }], + }; + const latestResponse: IOpalFinesOffencesRefData = { + ...offenceMockResponse, + refData: [{ ...offenceMockResponse.refData[0], offence_id: 222222, get_cjs_code: 'CD12345' }], + }; + + service.initOffenceCodeListener( + form, + 'code', + 'id', + destroy$, + getOffenceByCjsCode, + onResultSpy, + onConfirmChangeSpy, + ); + + form.get('code')?.setValue('ab12345'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + + form.get('code')?.setValue('cd12345'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + + firstLookup$.next(staleResponse); + expect(form.get('id')?.value).toBeNull(); + expect(onResultSpy).not.toHaveBeenCalled(); + + secondLookup$.next(latestResponse); + expect(form.get('id')?.value).toBe(222222); + expect(onResultSpy).toHaveBeenCalledTimes(1); + expect(onResultSpy).toHaveBeenCalledWith(latestResponse); + expect(onConfirmChangeSpy).toHaveBeenCalledWith(true); + }); + it('should mark code as invalid when response count is 0', () => { vi.useFakeTimers(); const invalidResponse = { count: 0, refData: [] }; @@ -201,6 +313,91 @@ describe('FinesMacOffenceDetailsService', () => { expect(onConfirmChangeSpy).toHaveBeenCalledWith(true); }); + it('should mark code as invalid when response count is greater than 1', () => { + vi.useFakeTimers(); + const multipleResponse: IOpalFinesOffencesRefData = { + count: 2, + refData: [offenceMockResponse.refData[0], { ...offenceMockResponse.refData[0], offence_id: 123456 }], + }; + getOffenceByCjsCode = () => of(multipleResponse); + + service.initOffenceCodeListener( + form, + 'code', + 'id', + destroy$, + getOffenceByCjsCode, + onResultSpy, + onConfirmChangeSpy, + ); + + form.get('code')?.setValue('zz99999'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + + expect(form.get('code')?.errors).toEqual({ invalidOffenceCode: true }); + expect(form.get('id')?.value).toBeNull(); + expect(onConfirmChangeSpy).toHaveBeenCalledWith(true); + }); + + it('should clear pending error and set lookup failed error when offence lookup request fails', () => { + vi.useFakeTimers(); + getOffenceByCjsCode = () => throwError(() => new Error('request failed')); + + service.initOffenceCodeListener( + form, + 'code', + 'id', + destroy$, + getOffenceByCjsCode, + onResultSpy, + onConfirmChangeSpy, + ); + + form.get('code')?.setValue('zz99999'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + + expect(form.get('code')?.errors).toEqual({ offenceCodeLookupFailed: true }); + expect(form.get('id')?.value).toBeNull(); + expect(onResultSpy).not.toHaveBeenCalled(); + expect(onConfirmChangeSpy).toHaveBeenLastCalledWith(false); + }); + + it('should ignore stale lookup failures from previous offence code values', () => { + vi.useFakeTimers(); + + const firstLookup$ = new Subject(); + const secondLookup$ = new Subject(); + getOffenceByCjsCode = vi.fn((code: string) => { + return code === 'AB12345' ? firstLookup$.asObservable() : secondLookup$.asObservable(); + }); + + service.initOffenceCodeListener( + form, + 'code', + 'id', + destroy$, + getOffenceByCjsCode, + onResultSpy, + onConfirmChangeSpy, + ); + + form.get('code')?.setValue('ab12345'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + + form.get('code')?.setValue('cd12345'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + + firstLookup$.error(new Error('stale failure')); + + expect(form.get('code')?.errors).toEqual({ offenceCodeValidationPending: true }); + expect(onConfirmChangeSpy).toHaveBeenCalledTimes(4); + expect(onResultSpy).not.toHaveBeenCalled(); + + secondLookup$.next(offenceMockResponse); + expect(form.get('id')?.value).toBe(314441); + expect(form.get('code')?.errors).toBeNull(); + }); + it('should not call populateHint for short code', () => { vi.useFakeTimers(); service.initOffenceCodeListener( diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts index 3519764552..64ea38be01 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts @@ -1,7 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { IFinesMacOffenceDetailsForm } from '../interfaces/fines-mac-offence-details-form.interface'; -import { FormGroup } from '@angular/forms'; -import { debounceTime, distinctUntilChanged, Observable, Subject, takeUntil, tap } from 'rxjs'; +import { FormControl, FormGroup } from '@angular/forms'; +import { debounceTime, distinctUntilChanged, Observable, Subject, takeUntil, tap, catchError, EMPTY } from 'rxjs'; import { FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES } from '../constants/fines-mac-offence-details-default-values.constant'; import { UtilsService } from '@hmcts/opal-frontend-common/services/utils-service'; import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface'; @@ -11,6 +11,33 @@ import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/in }) export class FinesMacOffenceDetailsService { public utilsService = inject(UtilsService); + + /** + * Adds or removes a single custom error key while preserving any other control errors. + * + * @param control - The control to update. + * @param errorKey - The custom error key to add or remove. + * @param hasError - True to set the error key, false to clear it. + */ + private setControlError(control: FormControl, errorKey: string, hasError: boolean): void { + const currentErrors = control.errors ?? {}; + + if (hasError) { + if (!currentErrors[errorKey]) { + control.setErrors({ ...currentErrors, [errorKey]: true }, { emitEvent: false }); + } + return; + } + + if (!currentErrors[errorKey]) { + return; + } + + const remainingErrors = { ...currentErrors }; + delete remainingErrors[errorKey]; + control.setErrors(Object.keys(remainingErrors).length ? remainingErrors : null, { emitEvent: false }); + } + /** * Reorders the imposition keys to maintain correct numbering. * @@ -135,48 +162,88 @@ export class FinesMacOffenceDetailsService { onResult: (result: any) => void, onConfirmChange?: (confirmed: boolean) => void, ): void { - const codeControl = form.controls[codeControlName]; - const idControl = form.controls[idControlName]; + const codeControl = form.controls[codeControlName] as FormControl; + const idControl = form.controls[idControlName] as FormControl; + let latestLookupRequest = 0; const populateHint = (code: string) => { - idControl.setValue(null); + const lookupRequest = ++latestLookupRequest; + idControl.setValue(null, { emitEvent: false }); + this.setControlError(codeControl, 'invalidOffenceCode', false); + this.setControlError(codeControl, 'offenceCodeLookupFailed', false); if (code?.length >= 7 && code?.length <= 8) { + this.setControlError(codeControl, 'offenceCodeValidationPending', true); + if (onConfirmChange) onConfirmChange(false); + const result$ = getOffenceByCjsCode(code).pipe( tap((response) => { - codeControl.setErrors(response.count === 0 ? { invalidOffenceCode: true } : null, { emitEvent: false }); + // Ignore stale responses that return after the user has changed the code. + if (lookupRequest !== latestLookupRequest || codeControl.value !== code) { + return; + } + + this.setControlError(codeControl, 'offenceCodeValidationPending', false); + this.setControlError(codeControl, 'offenceCodeLookupFailed', false); + this.setControlError(codeControl, 'invalidOffenceCode', response.count !== 1); idControl.setValue(response.count === 1 ? response.refData[0].offence_id : null, { emitEvent: false }); if (typeof onResult === 'function') { onResult(response); } + + if (onConfirmChange) onConfirmChange(true); + }), + catchError(() => { + // Ignore stale failures for previous lookups. + if (lookupRequest !== latestLookupRequest || codeControl.value !== code) { + return EMPTY; + } + + this.setControlError(codeControl, 'offenceCodeValidationPending', false); + this.setControlError(codeControl, 'invalidOffenceCode', false); + this.setControlError(codeControl, 'offenceCodeLookupFailed', true); + idControl.setValue(null, { emitEvent: false }); + if (onConfirmChange) onConfirmChange(false); + return EMPTY; }), takeUntil(destroy$), ); result$.subscribe(); - if (onConfirmChange) onConfirmChange(true); - } else if (onConfirmChange) { - onConfirmChange(false); + } else { + this.setControlError(codeControl, 'offenceCodeValidationPending', false); + this.setControlError(codeControl, 'offenceCodeLookupFailed', false); + if (onConfirmChange) onConfirmChange(false); } }; if (codeControl.value) { - populateHint(codeControl.value); + const upperCasedCode = this.utilsService.upperCaseAllLetters(codeControl.value); + codeControl.setValue(upperCasedCode, { emitEvent: false }); + populateHint(upperCasedCode); } codeControl.valueChanges .pipe( distinctUntilChanged(), tap((code: string) => { - code = this.utilsService.upperCaseAllLetters(code); - codeControl.setValue(code, { emitEvent: false }); + const upperCasedCode = this.utilsService.upperCaseAllLetters(code); + const isLookupLength = upperCasedCode?.length >= 7 && upperCasedCode?.length <= 8; + codeControl.setValue(upperCasedCode, { emitEvent: false }); + // Invalidate any in-flight lookup as soon as the input changes. + latestLookupRequest++; + idControl.setValue(null, { emitEvent: false }); + this.setControlError(codeControl, 'offenceCodeValidationPending', isLookupLength); + this.setControlError(codeControl, 'invalidOffenceCode', false); + this.setControlError(codeControl, 'offenceCodeLookupFailed', false); + if (onConfirmChange) onConfirmChange(false); }), debounceTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime), takeUntil(destroy$), ) .subscribe((code: string) => { - populateHint(code); + populateHint(this.utilsService.upperCaseAllLetters(code)); }); } }