feat: add comprehensive loading feedback (#18)
This commit is contained in:
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;
|
||||
|
||||
Reference in new issue
Block a user