feat: add comprehensive loading feedback (#18)

This commit is contained in:
nessar committed 2026-08-17 14:18:15 +02:00
1 parent 45de0cebcc
commit ec3319494b
47 files changed
+1753 -406

No files matched your search

@@ -27,6 +27,17 @@
background: var(--surface);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
}
fieldset {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.primary-action mat-spinner {
display: inline-block;
margin-right: 8px;
vertical-align: middle;
}
.form-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
@@ -1,66 +1,91 @@
<div class="form-page">
<header class="page-heading form-heading">
<div>
<button mat-button type="button" class="back-action" (click)="closed.emit()">← Retour</button>
<button
mat-button
type="button"
class="back-action"
[disabled]="saving()"
(click)="closed.emit()"
>
← Retour
</button>
<p class="eyebrow">Portefeuille</p>
<h1>{{ apartment() ? "Modifier l'appartement" : 'Ajouter un appartement' }}</h1>
<p>Renseignez les informations principales du bien.</p>
</div>
</header>
<form (submit)="save(); $event.preventDefault()" class="apartment-form">
<form
(submit)="save(); $event.preventDefault()"
class="apartment-form"
[attr.aria-busy]="saving()"
>
@if (saveError()) {
<div class="alert-error" role="alert">{{ saveError() }}</div>
}
@if (error()) {
<div class="alert-error" role="alert">{{ error() }}</div>
}
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Nom de l'appartement</mat-label
><input
matInput
type="text"
name="name"
[ngModel]="name()"
(ngModelChange)="name.set($event)"
placeholder="T2 Paris 15"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Prix d'acquisition</mat-label
><input
matInput
type="number"
name="purchasePrice"
[ngModel]="purchasePrice()"
(ngModelChange)="purchasePrice.set($event)"
min="1"
required
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input
matInput
type="text"
name="address"
[ngModel]="address()"
(ngModelChange)="address.set($event)"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
required
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Description ou notes</mat-label
><textarea
matInput
name="description"
[ngModel]="description()"
(ngModelChange)="description.set($event)"
rows="4"
placeholder="Étage, agencement, équipements…"
></textarea>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="closed.emit()">Annuler</button
><button mat-flat-button type="submit" class="primary-action">Enregistrer</button>
</div>
<fieldset [disabled]="saving()" [attr.inert]="saving() ? '' : null">
<div class="form-grid">
<mat-form-field appearance="outline"
><mat-label>Nom de l'appartement</mat-label
><input
matInput
type="text"
name="name"
[ngModel]="name()"
(ngModelChange)="name.set($event)"
placeholder="T2 Paris 15"
required
/></mat-form-field>
<mat-form-field appearance="outline"
><mat-label>Prix d'acquisition</mat-label
><input
matInput
type="number"
name="purchasePrice"
[ngModel]="purchasePrice()"
(ngModelChange)="purchasePrice.set($event)"
min="1"
required
/><span matTextSuffix>€</span></mat-form-field
>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Adresse complète</mat-label
><input
matInput
type="text"
name="address"
[ngModel]="address()"
(ngModelChange)="address.set($event)"
placeholder="45 rue des Entrepreneurs, 75015 Paris"
required
/></mat-form-field>
<mat-form-field appearance="outline" class="full-field"
><mat-label>Description ou notes</mat-label
><textarea
matInput
name="description"
[ngModel]="description()"
(ngModelChange)="description.set($event)"
rows="4"
placeholder="Étage, agencement, équipements…"
></textarea>
</mat-form-field>
</div>
<div class="form-actions">
<button mat-button type="button" (click)="closed.emit()">Annuler</button
><button mat-flat-button type="submit" class="primary-action">
<span aria-live="polite">
@if (saving()) {
<mat-spinner diameter="18" aria-hidden="true" /> Enregistrement…
} @else {
Enregistrer
}
</span>
</button>
</div>
</fieldset>
</form>
</div>
@@ -50,4 +50,17 @@ describe('ApartmentForm', () => {
description: 'Cinquième étage',
});
});
it('disables the whole form and preserves a save error while pending', () => {
const fixture = TestBed.createComponent(ApartmentForm);
fixture.componentRef.setInput('saving', true);
fixture.componentRef.setInput('saveError', 'Impossible d’enregistrer cet appartement.');
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('fieldset').disabled).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Enregistrement…');
expect(fixture.nativeElement.textContent).toContain(
'Impossible d’enregistrer cet appartement.',
);
});
});
@@ -3,16 +3,25 @@ import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { Apartment } from '../../services/apartment.service';
@Component({
selector: 'app-apartment-form',
imports: [FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule],
imports: [
FormsModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
],
templateUrl: './apartment-form.html',
styleUrl: './apartment-form.css',
})
export class ApartmentForm {
readonly apartment = input<Apartment | null>(null);
readonly saving = input(false);
readonly saveError = input('');
readonly closed = output<void>();
readonly saved = output<Apartment>();
@@ -27,6 +36,7 @@ export class ApartmentForm {
}
save(): void {
if (this.saving()) return;
if (!this.name().trim() || !this.address().trim() || this.purchasePrice() <= 0) {
this.error.set('Veuillez remplir tous les champs obligatoires (Nom, Adresse, Prix > 0).');
return;