Back AsyncComponent with a RemoteData tagged union
Introduce RemoteData<E,T> (Loading | Empty | Failure | Success) plus fromResource and an exhaustive foldRemote. The data lives ON the state, so "loaded without value" or "error with stale value" are unrepresentable. AsyncComponent now derives a single rd() and pulls value/error out via the fold instead of a loose State string. Public API (resource/isEmpty inputs, the four slot directives, the ASYNC array) is unchanged, so the dashboard, detail page, and async stories need no edits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
47
src/app/core/remote-data.ts
Normal file
47
src/app/core/remote-data.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Resource } from '@angular/core';
|
||||||
|
import { assertNever } from './fp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The four mutually-exclusive states of an async fetch, as a tagged union.
|
||||||
|
* Crucially the data lives ON the state: only `Failure` has an `error`, only
|
||||||
|
* `Success` has a `value`. "Loaded but no value" or "error with stale value"
|
||||||
|
* are unrepresentable — Richard Feldman's RemoteData.
|
||||||
|
*/
|
||||||
|
export type RemoteData<E, T> =
|
||||||
|
| { tag: 'Loading' }
|
||||||
|
| { tag: 'Empty' }
|
||||||
|
| { tag: 'Failure'; error: E }
|
||||||
|
| { tag: 'Success'; value: T };
|
||||||
|
|
||||||
|
/** Project Angular's loosely-typed Resource into a RemoteData value. */
|
||||||
|
export function fromResource<T>(
|
||||||
|
r: Resource<T>,
|
||||||
|
isEmpty: (v: T) => boolean = () => false,
|
||||||
|
): RemoteData<Error | undefined, T> {
|
||||||
|
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
|
||||||
|
if (r.status() === 'loading') return { tag: 'Loading' };
|
||||||
|
if (r.hasValue()) {
|
||||||
|
const v = r.value();
|
||||||
|
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
|
||||||
|
}
|
||||||
|
return { tag: 'Loading' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exhaustive fold: you must handle every case, checked at compile time. */
|
||||||
|
export function foldRemote<E, T, R>(
|
||||||
|
rd: RemoteData<E, T>,
|
||||||
|
h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },
|
||||||
|
): R {
|
||||||
|
switch (rd.tag) {
|
||||||
|
case 'Loading':
|
||||||
|
return h.loading();
|
||||||
|
case 'Empty':
|
||||||
|
return h.empty();
|
||||||
|
case 'Failure':
|
||||||
|
return h.failure(rd.error);
|
||||||
|
case 'Success':
|
||||||
|
return h.success(rd.value);
|
||||||
|
default:
|
||||||
|
return assertNever(rd);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { Resource } from '@angular/core';
|
|||||||
import { SpinnerComponent } from '../../atoms/spinner/spinner.component';
|
import { SpinnerComponent } from '../../atoms/spinner/spinner.component';
|
||||||
import { AlertComponent } from '../../atoms/alert/alert.component';
|
import { AlertComponent } from '../../atoms/alert/alert.component';
|
||||||
import { ButtonComponent } from '../../atoms/button/button.component';
|
import { ButtonComponent } from '../../atoms/button/button.component';
|
||||||
|
import { fromResource, foldRemote } from '../../core/remote-data';
|
||||||
|
|
||||||
/* Slot markers. Put on <ng-template> children of <app-async>. */
|
/* Slot markers. Put on <ng-template> children of <app-async>. */
|
||||||
@Directive({ selector: '[appAsyncLoaded]' })
|
@Directive({ selector: '[appAsyncLoaded]' })
|
||||||
@@ -23,27 +24,26 @@ export class AsyncErrorDirective {
|
|||||||
constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}
|
constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = 'loading' | 'error' | 'empty' | 'loaded';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders exactly ONE of loading / empty / error / loaded for a signal-based
|
* Renders exactly ONE of loading / empty / error / loaded for a signal-based
|
||||||
* resource (e.g. httpResource). The states are mutually exclusive by
|
* resource (e.g. httpResource). Built on a RemoteData tagged union (see
|
||||||
* construction, so the UI can never show two at once ("impossible states").
|
* core/remote-data.ts), so the states are mutually exclusive by construction —
|
||||||
* Unprovided slots fall back to sensible defaults.
|
* the UI can never show two at once ("impossible states"). Unprovided slots
|
||||||
|
* fall back to sensible defaults.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-async',
|
selector: 'app-async',
|
||||||
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
|
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
|
||||||
template: `
|
template: `
|
||||||
@switch (state()) {
|
@switch (rd().tag) {
|
||||||
@case ('loading') {
|
@case ('Loading') {
|
||||||
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
|
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
|
||||||
@else { <app-spinner /> }
|
@else { <app-spinner /> }
|
||||||
}
|
}
|
||||||
@case ('error') {
|
@case ('Failure') {
|
||||||
@if (errorTpl()) {
|
@if (errorTpl()) {
|
||||||
<ng-container [ngTemplateOutlet]="errorTpl()!.tpl"
|
<ng-container [ngTemplateOutlet]="errorTpl()!.tpl"
|
||||||
[ngTemplateOutletContext]="{ $implicit: resource().error(), retry: retry }" />
|
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }" />
|
||||||
} @else {
|
} @else {
|
||||||
<app-alert type="error">Er ging iets mis bij het laden van de gegevens.</app-alert>
|
<app-alert type="error">Er ging iets mis bij het laden van de gegevens.</app-alert>
|
||||||
<div style="margin-top:1rem">
|
<div style="margin-top:1rem">
|
||||||
@@ -51,11 +51,11 @@ type State = 'loading' | 'error' | 'empty' | 'loaded';
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@case ('empty') {
|
@case ('Empty') {
|
||||||
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
|
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
|
||||||
@else { <p class="rhc-paragraph">Geen gegevens gevonden.</p> }
|
@else { <p class="rhc-paragraph">Geen gegevens gevonden.</p> }
|
||||||
}
|
}
|
||||||
@case ('loaded') {
|
@case ('Success') {
|
||||||
<ng-container [ngTemplateOutlet]="loadedTpl().tpl"
|
<ng-container [ngTemplateOutlet]="loadedTpl().tpl"
|
||||||
[ngTemplateOutletContext]="{ $implicit: value() }" />
|
[ngTemplateOutletContext]="{ $implicit: value() }" />
|
||||||
}
|
}
|
||||||
@@ -71,18 +71,17 @@ export class AsyncComponent<T> {
|
|||||||
emptyTpl = contentChild(AsyncEmptyDirective);
|
emptyTpl = contentChild(AsyncEmptyDirective);
|
||||||
errorTpl = contentChild(AsyncErrorDirective);
|
errorTpl = contentChild(AsyncErrorDirective);
|
||||||
|
|
||||||
protected value = computed(() => {
|
// Single source of truth: the resource projected into a RemoteData union.
|
||||||
const r = this.resource();
|
protected rd = computed(() => fromResource(this.resource(), this.isEmpty()));
|
||||||
return r.hasValue() ? r.value() : undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
protected state = computed<State>(() => {
|
// value/error are pulled out via the exhaustive fold — only Success carries a
|
||||||
const r = this.resource();
|
// value, only Failure carries an error, so these can't lie.
|
||||||
if (r.status() === 'error') return 'error';
|
protected value = computed(() =>
|
||||||
if (r.status() === 'loading') return 'loading';
|
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),
|
||||||
if (r.hasValue()) return this.isEmpty()(r.value()) ? 'empty' : 'loaded';
|
);
|
||||||
return 'loading';
|
protected error = computed(() =>
|
||||||
});
|
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),
|
||||||
|
);
|
||||||
|
|
||||||
retry = () => {
|
retry = () => {
|
||||||
const r = this.resource();
|
const r = this.resource();
|
||||||
|
|||||||
Reference in New Issue
Block a user