Align nested lease validation flows (#50)

This commit is contained in:
nessar committed 2026-08-17 14:18:16 +02:00
1 parent 88d3f3c2a5
commit 41590a6a78
14 files changed
+886 -59

No files matched your search

@@ -23,15 +23,26 @@
<mat-checkbox
[checked]="isSelected(definition.id)"
(change)="toggleCatalog(definition.id, $event.checked)"
[attr.data-clause-invalid]="catalogError(definition.id) ? 'true' : null"
>
{{ definition.label }}
</mat-checkbox>
<p>{{ definition.description }}</p>
@if (catalogError(definition.id)) {
<p class="alert-error" data-clause-invalid="true" tabindex="-1">
{{ catalogError(definition.id) }}
</p>
}
@if (isSelected(definition.id) && definition.parameters.length) {
<div class="parameter-grid">
@for (parameter of definition.parameters; track parameter.name) {
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-clause-invalid]="
parameterErrorFor(definition.id, parameter) ? 'true' : null
"
>
<mat-label>{{ parameter.label }}</mat-label>
@switch (parameter.type) {
@case ('CHOICE') {
@@ -127,6 +138,11 @@
/>
}
}
@if (parameterErrorFor(definition.id, parameter)) {
<mat-error>{{
parameterErrorFor(definition.id, parameter)
}}</mat-error>
}
</mat-form-field>
}
</div>
@@ -150,7 +166,10 @@
<div class="custom-clauses">
@for (item of customClauses(); track item.index) {
<div class="custom-clause">
<mat-form-field appearance="outline">
<mat-form-field
appearance="outline"
[attr.data-clause-invalid]="customClauseError(item.index, item.clause) ? 'true' : null"
>
<mat-label>Clause personnalisée {{ $index + 1 }}</mat-label>
<textarea
matInput
@@ -159,6 +178,9 @@
[value]="item.clause.customText"
(input)="updateCustomClauseFromEvent(item.index, $event)"
></textarea>
@if (customClauseError(item.index, item.clause)) {
<mat-error>{{ customClauseError(item.index, item.clause) }}</mat-error>
}
</mat-form-field>
<button mat-button type="button" (click)="removeClause(item.index)">Retirer</button>
</div>
@@ -130,4 +130,22 @@ describe('LeaseClauseEditor', () => {
expect(getLeaseClauses).toHaveBeenNthCalledWith(1, 'STANDARD');
expect(getLeaseClauses).toHaveBeenNthCalledWith(2, 'COLOCATION');
});
it('clears only the corrected indexed parameter error', () => {
const fixture = createEditor();
const editor = fixture.componentInstance;
const changed = vi.fn();
editor.fieldChanged.subscribe(changed);
editor.toggleCatalog('VISITS', true);
fixture.componentRef.setInput('saveFieldErrors', [
{ field: 'specialClauses[0].parameters.startTime', code: 'required' },
{ field: 'specialClauses[0].parameters.day', code: 'invalid' },
]);
editor.setParameter('VISITS', 'startTime', '18:00');
expect(changed).toHaveBeenCalledWith('specialClauses[0].parameters.startTime');
expect(changed).not.toHaveBeenCalledWith('specialClauses[0].parameters.day');
expect(editor.parameterErrorFor('VISITS', definitions[0].parameters[3])).toContain('valide');
});
});
@@ -1,6 +1,7 @@
import {
Component,
DestroyRef,
ElementRef,
computed,
effect,
inject,
@@ -21,6 +22,7 @@ import {
LeaseService,
LeaseSpecialClause,
} from '../../services/lease.service';
import { FieldValidationError } from '../../services/http-error-message';
@Component({
selector: 'app-lease-clause-editor',
@@ -38,11 +40,14 @@ import {
export class LeaseClauseEditor {
private readonly leaseService = inject(LeaseService);
private readonly destroyRef = inject(DestroyRef);
private readonly element = inject<ElementRef<HTMLElement>>(ElementRef);
private requestId = 0;
readonly context = input.required<LeaseClauseContext>();
readonly specialClauses = input<LeaseSpecialClause[]>([]);
readonly specialClausesChange = output<LeaseSpecialClause[]>();
readonly fieldChanged = output<string>();
readonly saveFieldErrors = input<FieldValidationError[]>([]);
readonly definitions = signal<LeaseClauseDefinition[]>([]);
readonly loading = signal(true);
@@ -79,6 +84,10 @@ export class LeaseClauseEditor {
const definition = this.definitions().find(({ id }) => id === clause.catalogId);
if (!definition)
return `La clause « ${clause.catalogId} » n'est plus disponible. Retirez-la.`;
const invalid = definition.parameters.find((parameter) =>
Boolean(this.parameterError(clause, parameter, true)),
);
if (invalid) return 'Corrigez « ' + invalid.label + ' ».';
const missing = definition.parameters.find(
(parameter) => parameter.required && !clause.parameters[parameter.name]?.trim(),
);
@@ -104,15 +113,25 @@ export class LeaseClauseEditor {
return !this.validationMessage();
}
focusInvalid(): boolean {
const control = this.element.nativeElement.querySelector<HTMLElement>(
'[data-clause-invalid=true] input, [data-clause-invalid=true] textarea, [data-clause-invalid=true], .validation-error',
);
control?.focus();
return control !== null;
}
isSelected(id: string): boolean {
return this.specialClauses().some(({ catalogId }) => catalogId === id);
}
toggleCatalog(id: string, selected: boolean): void {
const index = this.clauseIndex(id);
const clauses = this.specialClauses().filter(({ catalogId }) => catalogId !== id);
this.emit(
selected ? [...clauses, { catalogId: id, parameters: {}, customText: '' }] : clauses,
);
this.fieldChanged.emit('specialClauses[' + index + '].catalogId');
}
parameterValue(catalogId: string, parameterName: string): string {
@@ -131,6 +150,9 @@ export class LeaseClauseEditor {
: clause,
),
);
this.fieldChanged.emit(
'specialClauses[' + this.clauseIndex(catalogId) + '].parameters.' + parameterName,
);
}
setParameterFromEvent(catalogId: string, parameterName: string, event: Event): void {
@@ -147,6 +169,7 @@ export class LeaseClauseEditor {
clauseIndex === index ? { ...clause, customText } : clause,
),
);
this.fieldChanged.emit('specialClauses[' + index + '].customText');
}
updateCustomClauseFromEvent(index: number, event: Event): void {
@@ -180,10 +203,80 @@ export class LeaseClauseEditor {
}
private emit(clauses: LeaseSpecialClause[]): void {
this.showValidationError.set(false);
this.specialClausesChange.emit(clauses);
}
parameterError(
clause: LeaseSpecialClause,
parameter: LeaseClauseDefinition['parameters'][number],
show = this.showValidationError(),
): string {
const path =
'specialClauses[' + this.clauseIndex(clause.catalogId) + '].parameters.' + parameter.name;
if (this.saveFieldErrors().some((error) => error.field === path)) {
return this.parameterMessage(parameter);
}
if (!show) return '';
const value = clause.parameters[parameter.name]?.trim() ?? '';
if (parameter.required && !value) return this.parameterMessage(parameter, true);
return value && !this.parameterValid(parameter, value) ? this.parameterMessage(parameter) : '';
}
parameterErrorFor(
catalogId: string,
parameter: LeaseClauseDefinition['parameters'][number],
): string {
const clause = this.specialClauses().find((item) => item.catalogId === catalogId);
return clause ? this.parameterError(clause, parameter) : '';
}
catalogError(catalogId: string): string {
const path = 'specialClauses[' + this.clauseIndex(catalogId) + '].catalogId';
return this.saveFieldErrors().some((error) => error.field === path)
? 'Cette clause n’est pas disponible pour ce bail.'
: '';
}
customClauseError(index: number, clause: LeaseSpecialClause): string {
const serverError = this.saveFieldErrors().some(
(error) => error.field === 'specialClauses[' + index + '].customText',
);
if (serverError || (this.showValidationError() && !clause.customText.trim())) {
return 'Renseignez le texte de la clause personnalisée ou retirez-la.';
}
return '';
}
private parameterMessage(
parameter: LeaseClauseDefinition['parameters'][number],
required = false,
): string {
if (required) return 'Renseignez « ' + parameter.label + ' ».';
return parameter.type === 'CHOICE'
? 'Sélectionnez une valeur proposée pour « ' + parameter.label + ' ».'
: 'Renseignez une valeur valide pour « ' + parameter.label + ' ».';
}
private parameterValid(
parameter: LeaseClauseDefinition['parameters'][number],
value: string,
): boolean {
if (parameter.type === 'TEXT' || parameter.type === 'TIME') return true;
if (parameter.type === 'CHOICE') return parameter.choices.some((choice) => choice.value === value);
if (parameter.type === 'NUMBER') return Number.isFinite(Number(value));
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return (
date.getUTCFullYear() === year &&
date.getUTCMonth() === month - 1 &&
date.getUTCDate() === day
);
}
private clauseIndex(catalogId: string | null): number {
return this.specialClauses().findIndex((clause) => clause.catalogId === catalogId);
}
private eventValue(event: Event): string {
const target = event.target;
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement