feat: refactor forms to use Angular Material components for improved consistency and user experience

This commit is contained in:
nessar committed 2026-08-17 14:18:17 +02:00
1 parent 9a3e90ac74
commit c133c4cc89
17 files changed
+174 -205

No files matched your search

@@ -59,8 +59,7 @@ dd {
padding-block-start: 1.25rem;
border-block-start: 1px solid var(--border, #dfe3e6);
}
fieldset,
label {
fieldset {
display: grid;
gap: 0.5rem;
min-width: 0;
@@ -74,16 +73,9 @@ legend {
margin-block-end: 0.75rem;
font-weight: 700;
}
input,
textarea {
mat-form-field {
min-width: 0;
min-height: 2.75rem;
box-sizing: border-box;
}
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent, #236b5d);
outline-offset: 0.15rem;
width: 100%;
}
.operation-error {
color: var(--danger, #b42318);
@@ -137,8 +137,8 @@
</button>
</div>
@if (notice().evidenceVersions.length) {
<label>
<span>Raison de la nouvelle version</span>
<mat-form-field appearance="outline">
<mat-label>Raison de la nouvelle version</mat-label>
<input
matInput
type="text"
@@ -146,7 +146,7 @@
[value]="evidenceReason()"
(input)="updateEvidenceReason($event)"
/>
</label>
</mat-form-field>
}
@if (evidenceError() || evidenceOperationError()) {
<p class="operation-error" role="alert">
@@ -168,8 +168,8 @@
>
<fieldset [disabled]="withdrawalPending()">
<legend>Accepter un retrait</legend>
<label>
<span>Date d’acceptation</span>
<mat-form-field appearance="outline">
<mat-label>Date d’acceptation</mat-label>
<input
matInput
id="withdrawal-date"
@@ -177,7 +177,7 @@
[formField]="withdrawalForm.acceptedOn"
(input)="withdrawalFieldChanged.emit('acceptedOn')"
/>
</label>
</mat-form-field>
@if (withdrawalDateError()) {
<p class="operation-error" role="alert">{{ withdrawalDateError() }}</p>
}
@@ -186,16 +186,16 @@
{{ serverError(withdrawalFieldErrors(), 'acceptedOn') }}
</p>
}
<label
><span>Raison</span
><textarea
<mat-form-field appearance="outline">
<mat-label>Raison</mat-label>
<textarea
matInput
id="withdrawal-reason"
rows="2"
[formField]="withdrawalForm.reason"
(input)="withdrawalFieldChanged.emit('reason')"
></textarea>
</label>
</mat-form-field>
@if (withdrawalReasonError()) {
<p class="operation-error" role="alert">
{{ withdrawalReasonError() }}
@@ -223,8 +223,8 @@
>
<fieldset [disabled]="invalidationPending()">
<legend>Invalider administrativement</legend>
<label>
<span>Date d’invalidation</span>
<mat-form-field appearance="outline">
<mat-label>Date d’invalidation</mat-label>
<input
matInput
id="invalidation-date"
@@ -232,7 +232,7 @@
[formField]="invalidationForm.invalidatedOn"
(input)="invalidationFieldChanged.emit('invalidatedOn')"
/>
</label>
</mat-form-field>
@if (invalidationDateError()) {
<p class="operation-error" role="alert">
{{ invalidationDateError() }}
@@ -243,16 +243,16 @@
{{ serverError(invalidationFieldErrors(), 'invalidatedOn') }}
</p>
}
<label
><span>Raison</span
><textarea
<mat-form-field appearance="outline">
<mat-label>Raison</mat-label>
<textarea
matInput
id="invalidation-reason"
rows="2"
[formField]="invalidationForm.reason"
(input)="invalidationFieldChanged.emit('reason')"
></textarea>
</label>
</mat-form-field>
@if (invalidationReasonError()) {
<p class="operation-error" role="alert">
{{ invalidationReasonError() }}
@@ -28,16 +28,9 @@ fieldset {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
gap: 1rem;
}
label:not(.confirmation-field) {
display: grid;
gap: 0.4rem;
mat-form-field {
min-width: 0;
}
input,
select {
min-width: 0;
min-height: 2.75rem;
box-sizing: border-box;
width: 100%;
}
.confirmation-field {
display: flex;
@@ -57,11 +50,6 @@ select {
margin-block-start: 0;
color: var(--text-muted, #5f6368);
}
input:focus-visible,
select:focus-visible {
outline: 2px solid var(--accent, #236b5d);
outline-offset: 0.15rem;
}
.operation-error {
color: var(--danger, #b42318);
}
@@ -8,8 +8,8 @@
>
<fieldset [disabled]="submitting()">
<div class="form-grid field-grid">
<label>
<span>Date de réception</span>
<mat-form-field appearance="outline">
<mat-label>Date de réception</mat-label>
<input
matInput
id="notice-received-on"
@@ -17,15 +17,15 @@
[formField]="noticeForm.receivedOn"
(input)="fieldWasChanged('receivedOn')"
/>
@if (receivedOnError()) {
<p class="operation-error" role="alert">{{ receivedOnError() }}</p>
}
@if (serverError('receivedOn')) {
<p class="operation-error" role="alert">{{ serverError('receivedOn') }}</p>
}
</label>
<label>
<span>Départ demandé</span>
</mat-form-field>
@if (receivedOnError()) {
<p class="operation-error" role="alert">{{ receivedOnError() }}</p>
}
@if (serverError('receivedOn')) {
<p class="operation-error" role="alert">{{ serverError('receivedOn') }}</p>
}
<mat-form-field appearance="outline">
<mat-label>Départ demandé</mat-label>
<input
matInput
id="notice-requested-departure-on"
@@ -33,38 +33,37 @@
[formField]="noticeForm.requestedDepartureOn"
(input)="fieldWasChanged('requestedDepartureOn')"
/>
@if (requestedDepartureOnError()) {
<p class="operation-error" role="alert">
{{ requestedDepartureOnError() }}
</p>
}
@if (serverError('requestedDepartureOn')) {
<p class="operation-error" role="alert">
{{ serverError('requestedDepartureOn') }}
</p>
}
</label>
</mat-form-field>
@if (requestedDepartureOnError()) {
<p class="operation-error" role="alert">
{{ requestedDepartureOnError() }}
</p>
}
@if (serverError('requestedDepartureOn')) {
<p class="operation-error" role="alert">
{{ serverError('requestedDepartureOn') }}
</p>
}
</div>
<label>
<span>Mode de remise</span>
<select
matNativeControl
<mat-form-field appearance="outline">
<mat-label>Mode de remise</mat-label>
<mat-select
id="notice-delivery-mode"
[formField]="noticeForm.deliveryMode"
(change)="fieldWasChanged('deliveryMode')"
(selectionChange)="fieldWasChanged('deliveryMode')"
>
<option value="">Choisissez un mode</option>
<mat-option value="">Choisissez un mode</mat-option>
@for (mode of deliveryModes; track mode.value) {
<option [value]="mode.value">{{ mode.label }}</option>
<mat-option [value]="mode.value">{{ mode.label }}</mat-option>
}
</select>
@if (deliveryModeError()) {
<p class="operation-error" role="alert">{{ deliveryModeError() }}</p>
}
</label>
</mat-select>
</mat-form-field>
@if (deliveryModeError()) {
<p class="operation-error" role="alert">{{ deliveryModeError() }}</p>
}
@if (model().deliveryMode === 'OTHER') {
<label>
<span>Mode de remise utilisé</span>
<mat-form-field appearance="outline">
<mat-label>Mode de remise utilisé</mat-label>
<input
matInput
id="notice-other-delivery-mode"
@@ -72,15 +71,15 @@
[formField]="noticeForm.otherDeliveryMode"
(input)="fieldWasChanged('otherDeliveryMode')"
/>
@if (otherDeliveryModeError()) {
<p class="operation-error" role="alert">{{ otherDeliveryModeError() }}</p>
}
@if (serverError('otherDeliveryMode')) {
<p class="operation-error" role="alert">
{{ serverError('otherDeliveryMode') }}
</p>
}
</label>
</mat-form-field>
@if (otherDeliveryModeError()) {
<p class="operation-error" role="alert">{{ otherDeliveryModeError() }}</p>
}
@if (serverError('otherDeliveryMode')) {
<p class="operation-error" role="alert">
{{ serverError('otherDeliveryMode') }}
</p>
}
<mat-checkbox
class="confirmation-field"
[formField]="noticeForm.nonStandardDeliveryConfirmed"
@@ -14,6 +14,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { NoticeDeliveryMode, RecordNoticeRequest } from '../../services/lease-end.service';
import { FieldValidationError } from '../../services/http-error-message';
@@ -46,6 +47,7 @@ const serverMessages: Record<string, Record<string, string>> = {
MatCheckboxModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
],
templateUrl: './lease-end-notice-form.html',
styleUrl: './lease-end-notice-form.css',
@@ -20,8 +20,7 @@
h2 {
margin-block-start: 0;
}
fieldset,
label:not(.confirmation-field) {
fieldset {
display: grid;
gap: 0.75rem;
min-width: 0;
@@ -31,11 +30,9 @@ fieldset {
padding: 0;
border: 0;
}
input,
textarea {
mat-form-field {
min-width: 0;
min-height: 2.75rem;
box-sizing: border-box;
width: 100%;
}
.confirmation-field {
display: flex;
@@ -54,11 +51,6 @@ textarea {
.report-date span {
color: var(--text-muted, #5f6368);
}
input:focus-visible,
textarea:focus-visible {
outline: 2px solid var(--accent, #236b5d);
outline-offset: 0.15rem;
}
.operation-error {
color: var(--danger, #b42318);
}
@@ -8,8 +8,8 @@
(submit)="submitAgreement(); $event.preventDefault()"
>
<fieldset [disabled]="agreementPending()">
<label>
<span>Date de fin convenue</span>
<mat-form-field appearance="outline">
<mat-label>Date de fin convenue</mat-label>
<input
matInput
id="agreement-end-on"
@@ -17,7 +17,7 @@
[formField]="agreementForm.endOn"
(input)="agreementFieldChanged.emit('endOn')"
/>
</label>
</mat-form-field>
@if (agreementDateError()) {
<p class="operation-error" role="alert">{{ agreementDateError() }}</p>
}
@@ -26,8 +26,8 @@
{{ serverError(agreementFieldErrors(), 'endOn') }}
</p>
}
<label>
<span>Raison de l’accord</span>
<mat-form-field appearance="outline">
<mat-label>Raison de l’accord</mat-label>
<textarea
matInput
id="agreement-reason"
@@ -35,7 +35,7 @@
[formField]="agreementForm.reason"
(input)="agreementFieldChanged.emit('reason')"
></textarea>
</label>
</mat-form-field>
@if (agreementReasonError()) {
<p class="operation-error" role="alert">{{ agreementReasonError() }}</p>
}
@@ -38,17 +38,8 @@ form label {
font-weight: 700;
}
select,
input[type='file'] {
box-sizing: border-box;
mat-form-field {
width: 100%;
min-height: 2.75rem;
max-width: 100%;
border: 1px solid var(--border-strong, #aebbb4);
border-radius: var(--radius-sm, 0.5rem);
background: var(--surface, #fff);
color: var(--text, #17211e);
font: inherit;
}
.attestation {
@@ -11,23 +11,22 @@
(submit)="submit(); $event.preventDefault()"
>
<fieldset [disabled]="pending()">
<label>
<span>Mode d’envoi</span>
<select
matNativeControl
<mat-form-field appearance="outline">
<mat-label>Mode d’envoi</mat-label>
<mat-select
class="delivery-method"
[formField]="applicationForm.deliveryMethod"
(change)="clearFieldError('deliveryMethod')"
(selectionChange)="clearFieldError('deliveryMethod')"
>
<option value="">Choisissez un mode</option>
<mat-option value="">Choisissez un mode</mat-option>
@for (method of deliveryMethods; track method.value) {
<option [value]="method.value">{{ method.label }}</option>
<mat-option [value]="method.value">{{ method.label }}</mat-option>
}
</select>
@if (fieldError('deliveryMethod')) {
<small class="field-error">{{ fieldError('deliveryMethod') }}</small>
}
</label>
</mat-select>
</mat-form-field>
@if (fieldError('deliveryMethod')) {
<small class="field-error">{{ fieldError('deliveryMethod') }}</small>
}
<div>
<span>Preuve d’envoi PDF (facultative)</span>
<input
@@ -1,6 +1,8 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TestBed } from '@angular/core/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { vi } from 'vitest';
import { RentRevisionResponse } from '../../services/lease.service';
import { RentRevisionApplicationComponent } from './rent-revision-application';
@@ -65,10 +67,11 @@ describe('RentRevisionApplicationComponent', () => {
expect(fixture.nativeElement.textContent).toContain('Choisissez le mode d’envoi.');
http.expectNone((request) => request.method === 'POST');
const delivery: HTMLSelectElement = fixture.nativeElement.querySelector('.delivery-method');
delivery.value = 'EMAIL';
delivery.dispatchEvent(new Event('input'));
await fixture.whenStable();
const loader = TestbedHarnessEnvironment.loader(fixture);
const delivery = await loader.getHarness(
MatSelectHarness.with({ selector: '.delivery-method' }),
);
await delivery.clickOptions({ text: 'Courriel' });
fixture.detectChanges();
fixture.nativeElement.querySelector('.apply-submit').click();
fixture.detectChanges();
@@ -82,10 +85,11 @@ describe('RentRevisionApplicationComponent', () => {
const fixture = createComponent();
const applied = vi.fn();
fixture.componentInstance.applied.subscribe(applied);
const delivery: HTMLSelectElement = fixture.nativeElement.querySelector('.delivery-method');
delivery.value = 'REGISTERED_LETTER';
delivery.dispatchEvent(new Event('input'));
await fixture.whenStable();
const loader = TestbedHarnessEnvironment.loader(fixture);
const delivery = await loader.getHarness(
MatSelectHarness.with({ selector: '.delivery-method' }),
);
await delivery.clickOptions({ text: 'Lettre recommandée' });
fixture.nativeElement.querySelector('.delivery-confirmed input').click();
const proof = new File(['pdf'], 'preuve-envoi.pdf', { type: 'application/pdf' });
const proofInput: HTMLInputElement =
@@ -6,6 +6,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { finalize } from 'rxjs';
import {
ApplyRentRevisionResponse,
@@ -32,6 +33,7 @@ const frenchDate = new Intl.DateTimeFormat('fr-FR', {
MatCheckboxModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
],
templateUrl: './rent-revision-application.html',
styleUrl: './rent-revision-application.css',
@@ -21,19 +21,18 @@
>
<mat-form-field appearance="outline">
<mat-label>Nouvelle série officielle</mat-label>
<select
matNativeControl
<mat-select
class="series-override"
[formField]="requestForm.seriesIdOverride"
(change)="onSeriesChanged()"
(selectionChange)="onSeriesChanged()"
>
<option value="">Série prévue dans le bail</option>
<option value="001515333">Métropole — 001515333</option>
<option value="010760507">Corse — 010760507</option>
<option value="010760509">
<mat-option value="">Série prévue dans le bail</mat-option>
<mat-option value="001515333">Métropole — 001515333</mat-option>
<mat-option value="010760507">Corse — 010760507</mat-option>
<mat-option value="010760509">
Départements d’outre-mer (article 73) — 010760509
</option>
</select>
</mat-option>
</mat-select>
</mat-form-field>
@if (fieldError('seriesIdOverride')) {
<small class="field-error">{{ fieldError('seriesIdOverride') }}</small>
@@ -1,6 +1,8 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TestBed } from '@angular/core/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { RentRevisionPreview } from '../../services/lease.service';
import { WorkspaceService } from '../../services/workspace.service';
import { RentRevisionPreviewComponent } from './rent-revision-preview';
@@ -148,19 +150,20 @@ describe('RentRevisionPreviewComponent', () => {
it('requires an attestation before overriding the INSEE series', async () => {
const fixture = createComponent();
const series: HTMLSelectElement = fixture.nativeElement.querySelector('.series-override');
expect([...series.options].map((option) => option.value)).toEqual([
'',
'001515333',
'010760507',
'010760509',
const loader = TestbedHarnessEnvironment.loader(fixture);
const series = await loader.getHarness(
MatSelectHarness.with({ selector: '.series-override' }),
);
await series.open();
const options = await series.getOptions();
expect(await Promise.all(options.map((option) => option.getText()))).toEqual([
'Série prévue dans le bail',
'Métropole — 001515333',
'Corse — 010760507',
'Départements d’outre-mer (article 73) — 010760509',
]);
expect(series.textContent).toContain('Métropole — 001515333');
expect(series.textContent).toContain('Corse — 010760507');
expect(series.textContent).toContain('Départements d’outre-mer (article 73) — 010760509');
series.value = '001515333';
series.dispatchEvent(new Event('input'));
await fixture.whenStable();
await series.close();
await series.clickOptions({ text: 'Métropole — 001515333' });
fixture.detectChanges();
fixture.nativeElement.querySelector('.preview-submit').click();
@@ -7,6 +7,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { finalize } from 'rxjs';
import {
LeaseService,
@@ -42,6 +43,7 @@ const indexFormatter = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 4
MatExpansionModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
RentRevisionProposalsComponent,
],
templateUrl: './rent-revision-preview.html',
@@ -36,28 +36,15 @@
gap: var(--space-3, 0.75rem);
}
.field-grid label,
.proposal-field,
.proposal-warnings,
.attestation {
display: grid;
gap: var(--space-2, 0.5rem);
}
.field-grid label {
font-size: 0.78rem;
font-weight: 700;
}
.field-grid input {
box-sizing: border-box;
mat-form-field {
width: 100%;
min-height: 2.75rem;
padding-inline: var(--space-3, 0.75rem);
border: 1px solid var(--border-strong, #aebbb4);
border-radius: var(--radius-sm, 0.5rem);
background: var(--surface, #fff);
color: var(--text, #17211e);
font: inherit;
}
.bounds,
@@ -22,50 +22,56 @@
<fieldset [disabled]="creating()">
<h4>Créer la proposition et sa lettre</h4>
<div class="field-grid">
<label>
<span>Date de la demande</span>
<input
matInput
type="date"
class="requested-on"
[formField]="proposalForm.requestedOn"
(input)="clearFieldError('requestedOn')"
/>
<div class="proposal-field">
<mat-form-field appearance="outline">
<mat-label>Date de la demande</mat-label>
<input
matInput
type="date"
class="requested-on"
[formField]="proposalForm.requestedOn"
(input)="clearFieldError('requestedOn')"
/>
</mat-form-field>
@if (fieldError('requestedOn')) {
<small class="field-error">{{ fieldError('requestedOn') }}</small>
}
</label>
<label>
<span>Nouveau loyer choisi</span>
<input
matInput
type="number"
step="0.01"
class="chosen-rent"
[formField]="proposalForm.chosenRent"
(input)="clearFieldError('chosenRent')"
/>
@if (fieldError('chosenRent')) {
<small class="field-error">{{ fieldError('chosenRent') }}</small>
}
</label>
@if (currentPreview.chargeCeiling !== null) {
<label>
<span>Nouvelles charges choisies</span>
</div>
<div class="proposal-field">
<mat-form-field appearance="outline">
<mat-label>Nouveau loyer choisi</mat-label>
<input
matInput
type="number"
step="0.01"
class="chosen-charges"
[formField]="proposalForm.chosenCharges"
(input)="clearFieldError('chosenCharges')"
class="chosen-rent"
[formField]="proposalForm.chosenRent"
(input)="clearFieldError('chosenRent')"
/>
</mat-form-field>
@if (fieldError('chosenRent')) {
<small class="field-error">{{ fieldError('chosenRent') }}</small>
}
</div>
@if (currentPreview.chargeCeiling !== null) {
<div class="proposal-field">
<mat-form-field appearance="outline">
<mat-label>Nouvelles charges choisies</mat-label>
<input
matInput
type="number"
step="0.01"
class="chosen-charges"
[formField]="proposalForm.chosenCharges"
(input)="clearFieldError('chosenCharges')"
/>
</mat-form-field>
@if (fieldError('chosenCharges')) {
<small class="field-error">{{
fieldError('chosenCharges')
}}</small>
}
</label>
</div>
}
</div>
<p class="bounds">
@@ -1,6 +1,8 @@
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { TestBed } from '@angular/core/testing';
import { MatSelectHarness } from '@angular/material/select/testing';
import { vi } from 'vitest';
import {
RentRevisionPreview,
@@ -258,10 +260,11 @@ describe('RentRevisionProposalsComponent', () => {
fixture.nativeElement.querySelectorAll('app-rent-revision-application'),
).toHaveLength(2);
expect(fixture.nativeElement.querySelector('.proposal-form')).not.toBeNull();
const delivery: HTMLSelectElement = fixture.nativeElement.querySelector('.delivery-method');
delivery.value = 'EMAIL';
delivery.dispatchEvent(new Event('input'));
await fixture.whenStable();
const loader = TestbedHarnessEnvironment.loader(fixture);
const delivery = await loader.getHarness(
MatSelectHarness.with({ selector: '.delivery-method' }),
);
await delivery.clickOptions({ text: 'Courriel' });
fixture.nativeElement.querySelector('.delivery-confirmed input').click();
fixture.detectChanges();
fixture.nativeElement.querySelector('.apply-submit').click();