Align nested lease validation flows (#50)
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user