feat(fp): WP-07 — brief on the shared idioms + RemoteData MDX
Collapse brief.store's busy signal + nullable lastError into one Idle | Busy | Failed union (saveState gets matching tag-object style), and route brief.page's load through RemoteData + <app-async> instead of a hand-rolled @switch, via a BriefStore.remoteData projection of the machine's existing loading/failed tags -- the machine keeps owning the letter's own status lifecycle untouched. New brief.store.spec.ts covers the Busy->Idle/Failed transitions; new Foundations/RemoteData & Async MDX page documents the pattern and the WP-06 typed-loaded-slot fallback. Deviation from the original plan recorded in the WP file.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { BriefStore } from '@brief/application/brief.store';
|
||||
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
||||
|
||||
@@ -11,13 +11,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
this just wires signals to the organism and events back to store commands. */
|
||||
@Component({
|
||||
selector: 'app-brief-page',
|
||||
imports: [
|
||||
PageShellComponent,
|
||||
SpinnerComponent,
|
||||
AlertComponent,
|
||||
ButtonComponent,
|
||||
LetterComposerComponent,
|
||||
],
|
||||
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
|
||||
styles: [
|
||||
`
|
||||
.brief-toolbar {
|
||||
@@ -39,39 +33,38 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
<app-alert type="error">{{ err }}</app-alert>
|
||||
}
|
||||
|
||||
@switch (model().tag) {
|
||||
@case ('loading') {
|
||||
<app-spinner />
|
||||
}
|
||||
@case ('failed') {
|
||||
<app-async [data]="store.remoteData()">
|
||||
<ng-template appAsyncError>
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
}
|
||||
@case ('loaded') {
|
||||
<div class="brief-toolbar">
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
<app-letter-composer
|
||||
[brief]="brief()!"
|
||||
[availablePassages]="availablePassages()"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[canEdit]="store.canEdit()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
[canSend]="store.canSend()"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
(edit)="store.edit($event)"
|
||||
(submit)="store.submit()"
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoaded>
|
||||
@if (loaded(); as s) {
|
||||
<div class="brief-toolbar">
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
</div>
|
||||
<app-letter-composer
|
||||
[brief]="s.brief"
|
||||
[availablePassages]="s.availablePassages"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[canEdit]="store.canEdit()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
[canSend]="store.canSend()"
|
||||
[canSubmit]="store.canSubmit()"
|
||||
[busy]="store.busy()"
|
||||
(edit)="store.edit($event)"
|
||||
(submit)="store.submit()"
|
||||
(approve)="store.approve()"
|
||||
(reject)="store.reject($event)"
|
||||
(send)="store.send()"
|
||||
/>
|
||||
}
|
||||
</ng-template>
|
||||
</app-async>
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
@@ -92,12 +85,12 @@ export class BriefPage {
|
||||
|
||||
/** Debounced-save state, surfaced in a polite live region. */
|
||||
protected saveText = computed(() => {
|
||||
switch (this.store.saveState()) {
|
||||
case 'saving':
|
||||
switch (this.store.saveState().tag) {
|
||||
case 'Saving':
|
||||
return this.savingText;
|
||||
case 'saved':
|
||||
case 'Saved':
|
||||
return this.savedText;
|
||||
case 'error':
|
||||
case 'Error':
|
||||
return this.saveErrorText;
|
||||
default:
|
||||
return '';
|
||||
@@ -112,15 +105,13 @@ export class BriefPage {
|
||||
void this.store.resetDemo();
|
||||
}
|
||||
|
||||
// Narrow the loaded state for the template.
|
||||
protected brief() {
|
||||
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
|
||||
directive's context can't inherit a generic from a sibling host input, so the
|
||||
Success value is unwrapped here instead of through `let-`. */
|
||||
protected readonly loaded = computed(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s.brief : null;
|
||||
}
|
||||
protected availablePassages() {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s.availablePassages : [];
|
||||
}
|
||||
return s.tag === 'loaded' ? s : undefined;
|
||||
});
|
||||
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
|
||||
Reference in New Issue
Block a user