Skip to content

Commit eff02a8

Browse files
authored
2466 Validation C&E Form Part II (#2489)
* feat: added frontend validation and finalize button to send to homepage * feat: added backend validator service and responsible parties debounce time * chore: updated tests * chore: chanege dateSubmitted to dateOpened
1 parent d032ece commit eff02a8

File tree

12 files changed

+1012
-16
lines changed

12 files changed

+1012
-16
lines changed

alcs-frontend/src/app/features/compliance-and-enforcement/draft/draft.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ <h2>C&E File ID: {{ file?.fileNumber }}</h2>
4545
</div>
4646
<div class="right-actions">
4747
<button type="button" mat-stroked-button color="primary" (click)="onSaveDraftClicked()">Save Draft & Exit</button>
48-
<button type="button" mat-flat-button color="primary">Finish & Create File</button>
48+
<button type="button" mat-flat-button color="primary" (click)="onFinishCreateFileClicked()">Finish & Create File</button>
4949
</div>
5050
</div>
5151
</form>

alcs-frontend/src/app/features/compliance-and-enforcement/draft/draft.component.ts

Lines changed: 165 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
import { ComplianceAndEnforcementService } from '../../../services/compliance-and-enforcement/compliance-and-enforcement.service';
2121
import { OverviewComponent } from '../overview/overview.component';
2222
import { ToastService } from '../../../services/toast/toast.service';
23-
import { FormGroup } from '@angular/forms';
23+
import { FormArray, FormGroup } from '@angular/forms';
2424
import { SubmitterComponent } from '../submitter/submitter.component';
2525
import { ComplianceAndEnforcementSubmitterDto } from '../../../services/compliance-and-enforcement/submitter/submitter.dto';
2626
import { ComplianceAndEnforcementSubmitterService } from '../../../services/compliance-and-enforcement/submitter/submitter.service';
@@ -257,7 +257,9 @@ export class DraftComponent implements OnInit, AfterViewInit, OnDestroy {
257257
// Load property data
258258
if (this.file.uuid) {
259259
try {
260-
this.property = await this.complianceAndEnforcementPropertyService.fetchByFileUuid(this.file.uuid);
260+
const properties = await this.complianceAndEnforcementPropertyService.fetchParcels(this.file.uuid);
261+
262+
this.property = properties[0];
261263

262264
if (this.propertyComponent && this.property) {
263265
this.propertyComponent.property = this.property;
@@ -354,6 +356,167 @@ export class DraftComponent implements OnInit, AfterViewInit, OnDestroy {
354356
}
355357
}
356358

359+
async onFinishCreateFileClicked() {
360+
// Ensure child components and file exist
361+
if (!this.overviewComponent || !this.submitterComponent || !this.propertyComponent || !this.file?.uuid || !this.responsiblePartiesComponent) {
362+
this.toastService.showErrorToast('Something went wrong, please refresh the page and try again');
363+
return;
364+
}
365+
366+
// Trigger validation across all child forms
367+
const controlsToValidate: FormGroup[] = [
368+
this.overviewComponent.form,
369+
this.submitterComponent.form,
370+
this.propertyComponent.form,
371+
];
372+
373+
controlsToValidate.forEach((fg) => {
374+
fg.markAllAsTouched();
375+
fg.updateValueAndValidity({ onlySelf: false, emitEvent: false });
376+
});
377+
378+
// Ensure property local government display control syncs validation (may have to go back to the local gov validation)
379+
try {
380+
this.propertyComponent.onLocalGovernmentBlur();
381+
} catch {}
382+
this.propertyComponent.localGovernmentControl.markAsTouched();
383+
this.propertyComponent.localGovernmentControl.updateValueAndValidity({ onlySelf: false, emitEvent: false });
384+
385+
// Trigger validation for Responsible Parties form array and nested director forms
386+
if (this.responsiblePartiesComponent?.form) {
387+
this.responsiblePartiesComponent.form.controls.forEach((group) => {
388+
group.markAllAsTouched();
389+
group.updateValueAndValidity({ onlySelf: false, emitEvent: false });
390+
const directors = group.get('directors') as FormArray | null;
391+
directors?.controls.forEach((dg) => {
392+
dg.markAllAsTouched();
393+
dg.updateValueAndValidity({ onlySelf: false, emitEvent: false });
394+
});
395+
});
396+
}
397+
398+
// Validate that at least one responsible party is added
399+
const hasValidResponsibleParties = this.responsiblePartiesComponent?.validateRequiredParties() ?? false;
400+
401+
// If any form is invalid, show error toast and scroll to first error
402+
const hasInvalid =
403+
controlsToValidate.some((fg) => fg.invalid) ||
404+
this.propertyComponent.localGovernmentControl.invalid ||
405+
(this.responsiblePartiesComponent?.form.controls.some((g) => g.invalid) ?? false) ||
406+
!hasValidResponsibleParties;
407+
if (hasInvalid) {
408+
this.toastService.showErrorToast('Please correct all errors before submitting the form');
409+
// Attempt to scroll to first element with .ng-invalid within the form ( will check with SO if this is necessary)
410+
411+
const el = document.getElementsByClassName('ng-invalid');
412+
if (el && el.length > 0) {
413+
const target = Array.from(el).find((n) => n.nodeName !== 'FORM') as HTMLElement | undefined;
414+
target?.scrollIntoView({ behavior: 'smooth', block: 'center' });
415+
}
416+
417+
return;
418+
}
419+
420+
// Persist latest values before finalizing (same as save but without navigate)
421+
const overviewUpdate = this.overviewComponent.$changes.getValue();
422+
const submitterUpdate = this.submitterComponent.$changes.getValue();
423+
const propertyUpdate = this.propertyComponent.$changes.getValue();
424+
425+
try {
426+
await firstValueFrom(this.complianceAndEnforcementService.update(this.file.uuid, overviewUpdate));
427+
428+
if (this.submitter?.uuid) {
429+
await firstValueFrom(this.complianceAndEnforcementSubmitterService.update(this.submitter.uuid, submitterUpdate));
430+
} else {
431+
this.submitter = await firstValueFrom(this.complianceAndEnforcementSubmitterService.create({
432+
...submitterUpdate,
433+
fileUuid: this.file.uuid,
434+
}));
435+
}
436+
437+
if (this.property?.uuid) {
438+
await firstValueFrom(this.complianceAndEnforcementPropertyService.update(this.property.uuid, propertyUpdate));
439+
} else {
440+
this.property = await firstValueFrom(
441+
this.complianceAndEnforcementPropertyService.create({
442+
fileUuid: this.file.uuid,
443+
...cleanPropertyUpdate(propertyUpdate),
444+
}),
445+
);
446+
}
447+
448+
// Mark file as submitted
449+
await firstValueFrom(this.complianceAndEnforcementService.update(this.file.uuid, {
450+
dateOpened: Date.now()
451+
}));
452+
// Now submit the form - this will run backend validation
453+
await this.complianceAndEnforcementService.submit(this.file.uuid);
454+
455+
this.toastService.showSuccessToast('C&E file created');
456+
await this.router.navigate(['/home']);
457+
} catch (error: any) {
458+
// Check if it's a validation error from the backend
459+
if (error.status === 400 && error.error?.message?.includes('Validation failed')) {
460+
this.toastService.showErrorToast('Please correct all errors before submitting the form');
461+
462+
// Trigger client-side validation to show field errors
463+
this.triggerClientSideValidation();
464+
465+
// Scroll to first error
466+
setTimeout(() => {
467+
const el = document.getElementsByClassName('ng-invalid');
468+
if (el && el.length > 0) {
469+
const target = Array.from(el).find((n) => n.nodeName !== 'FORM') as HTMLElement | undefined;
470+
target?.scrollIntoView({ behavior: 'smooth', block: 'center' });
471+
}
472+
}, 100);
473+
} else {
474+
this.toastService.showErrorToast('Failed to create C&E file. Please try again.');
475+
}
476+
}
477+
}
478+
479+
private triggerClientSideValidation() {
480+
// Trigger validation across all child forms to show field errors
481+
const controlsToValidate: FormGroup[] = [
482+
this.overviewComponent?.form,
483+
this.submitterComponent?.form,
484+
this.propertyComponent?.form,
485+
].filter(Boolean) as FormGroup[];
486+
487+
controlsToValidate.forEach((fg) => {
488+
fg.markAllAsTouched();
489+
fg.updateValueAndValidity({ onlySelf: false, emitEvent: false });
490+
});
491+
492+
// Ensure property local government display control syncs validation
493+
if (this.propertyComponent) {
494+
try {
495+
this.propertyComponent.onLocalGovernmentBlur();
496+
} catch (error) {
497+
// Local government blur validation failed, continue
498+
}
499+
this.propertyComponent.localGovernmentControl.markAsTouched();
500+
this.propertyComponent.localGovernmentControl.updateValueAndValidity({ onlySelf: false, emitEvent: false });
501+
}
502+
503+
// Trigger validation for Responsible Parties form array and nested director forms
504+
if (this.responsiblePartiesComponent?.form) {
505+
this.responsiblePartiesComponent.form.controls.forEach((group) => {
506+
group.markAllAsTouched();
507+
group.updateValueAndValidity({ onlySelf: false, emitEvent: false });
508+
509+
const directors = group.get('directors') as FormArray | null;
510+
if (directors) {
511+
directors.controls.forEach((dg) => {
512+
dg.markAllAsTouched();
513+
dg.updateValueAndValidity({ onlySelf: false, emitEvent: false });
514+
});
515+
}
516+
});
517+
}
518+
}
519+
357520
ngOnDestroy(): void {
358521
this.$destroy.next();
359522
this.$destroy.complete();

alcs-frontend/src/app/features/compliance-and-enforcement/responsible-parties/responsible-parties.component.html

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -226,17 +226,24 @@ <h4 class="party-type-title">{{ partyForm.get('partyType')?.value }}</h4>
226226
</form>
227227
</div>
228228

229-
<!-- Add Party Button -->
230-
<button
231-
mat-flat-button
232-
color="primary"
233-
[matMenuTriggerFor]="addPartyMenu"
234-
class="add-party-button"
235-
>
236-
<span *ngIf="form.length === 0">+ ADD PARTY</span>
237-
<span *ngIf="form.length > 0">+ ADD ANOTHER PARTY</span>
238-
<mat-icon class="dropdown-arrow">arrow_drop_down</mat-icon>
239-
</button>
229+
<!-- Add Party Button with Error Message -->
230+
<div class="add-party-section">
231+
<button
232+
mat-flat-button
233+
color="primary"
234+
[matMenuTriggerFor]="addPartyMenu"
235+
class="add-party-button"
236+
>
237+
<span *ngIf="form.length === 0">+ ADD PARTY</span>
238+
<span *ngIf="form.length > 0">+ ADD ANOTHER PARTY</span>
239+
<mat-icon class="dropdown-arrow">arrow_drop_down</mat-icon>
240+
</button>
241+
242+
<div *ngIf="showRequiredError && !isPropertyCrown" class="validation-error">
243+
<mat-icon class="error-icon">warning</mat-icon>
244+
<span>This field is required</span>
245+
</div>
246+
</div>
240247

241248

242249

alcs-frontend/src/app/features/compliance-and-enforcement/responsible-parties/responsible-parties.component.scss

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,8 +301,28 @@
301301
}
302302
}
303303

304-
.add-party-button {
304+
.add-party-section {
305305
margin-top: 16px;
306+
display: flex;
307+
align-items: center;
308+
gap: 12px;
309+
310+
.validation-error {
311+
display: flex;
312+
align-items: center;
313+
gap: 8px;
314+
color: $error-color;
315+
font-size: 0.875rem;
316+
317+
.error-icon {
318+
font-size: 18px;
319+
width: 18px;
320+
height: 18px;
321+
}
322+
}
323+
}
324+
325+
.add-party-button {
306326
display: flex;
307327
align-items: center;
308328
justify-content: center;

alcs-frontend/src/app/features/compliance-and-enforcement/responsible-parties/responsible-parties.component.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Component, Input, OnDestroy, OnInit, ViewChild } from '@angular/core';
2-
import { BehaviorSubject, Subject, takeUntil, firstValueFrom, EMPTY, catchError } from 'rxjs';
2+
import { BehaviorSubject, Subject, takeUntil, firstValueFrom, EMPTY, catchError, debounceTime } from 'rxjs';
33
import { FormArray, FormControl, FormGroup, Validators } from '@angular/forms';
44
import { MatDialog } from '@angular/material/dialog';
55
import moment, { Moment } from 'moment';
@@ -15,6 +15,7 @@ import { ResponsiblePartiesService } from '../../../services/compliance-and-enfo
1515
import { ConfirmationDialogService } from '../../../shared/confirmation-dialog/confirmation-dialog.service';
1616
import { ToastService } from '../../../services/toast/toast.service';
1717
import { strictEmailValidator } from '../../../shared/validators/email-validator';
18+
import { C_E_AUTOSAVE_DEBOUNCE_MS } from '../constants';
1819

1920
@Component({
2021
selector: 'app-compliance-and-enforcement-responsible-parties',
@@ -37,6 +38,7 @@ export class ResponsiblePartiesComponent implements OnInit, OnDestroy {
3738
form = new FormArray<FormGroup>([]);
3839

3940
isLoading = false;
41+
showRequiredError = false;
4042

4143
// Prevent duplicate create calls for the same form during rapid value changes
4244
private creatingForms = new WeakSet<FormGroup>();
@@ -165,6 +167,7 @@ export class ResponsiblePartiesComponent implements OnInit, OnDestroy {
165167
subscribeToFormChanges(partyForm: FormGroup, existingParty?: ResponsiblePartyDto) {
166168
partyForm.valueChanges
167169
.pipe(
170+
debounceTime(C_E_AUTOSAVE_DEBOUNCE_MS),
168171
takeUntil(this.$destroy),
169172
catchError((error) => {
170173
console.error('Error in form changes', error);
@@ -411,6 +414,22 @@ export class ResponsiblePartiesComponent implements OnInit, OnDestroy {
411414
this.buildFormArray();
412415
}
413416

417+
validateRequiredParties(): boolean {
418+
// Only require parties for fee simple (non-Crown) properties
419+
if (this.isPropertyCrown) {
420+
this.showRequiredError = false;
421+
return true;
422+
}
423+
424+
const hasParties = this.form.length > 0;
425+
this.showRequiredError = !hasParties;
426+
return hasParties;
427+
}
428+
429+
markAsRequiredError() {
430+
this.showRequiredError = true;
431+
}
432+
414433
ngOnDestroy(): void {
415434
this.$destroy.next();
416435
this.$destroy.complete();

alcs-frontend/src/app/services/compliance-and-enforcement/compliance-and-enforcement.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,8 @@ export class ComplianceAndEnforcementService {
7878
async delete(uuid: string): Promise<UpdateComplianceAndEnforcementDto> {
7979
return await firstValueFrom(this.http.delete<UpdateComplianceAndEnforcementDto>(`${this.url}/${uuid}`));
8080
}
81+
82+
async submit(uuid: string): Promise<ComplianceAndEnforcementDto> {
83+
return await firstValueFrom(this.http.post<ComplianceAndEnforcementDto>(`${this.url}/${uuid}/submit`, {}));
84+
}
8185
}

0 commit comments

Comments
 (0)