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 { AlertComponent } from '../../atoms/alert/alert.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>. */
|
||||
@Directive({ selector: '[appAsyncLoaded]' })
|
||||
@@ -23,27 +24,26 @@ export class AsyncErrorDirective {
|
||||
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
|
||||
* resource (e.g. httpResource). The states are mutually exclusive by
|
||||
* construction, so the UI can never show two at once ("impossible states").
|
||||
* Unprovided slots fall back to sensible defaults.
|
||||
* resource (e.g. httpResource). Built on a RemoteData tagged union (see
|
||||
* core/remote-data.ts), so the states are mutually exclusive by construction —
|
||||
* the UI can never show two at once ("impossible states"). Unprovided slots
|
||||
* fall back to sensible defaults.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-async',
|
||||
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
|
||||
template: `
|
||||
@switch (state()) {
|
||||
@case ('loading') {
|
||||
@switch (rd().tag) {
|
||||
@case ('Loading') {
|
||||
@if (loadingTpl()) { <ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" /> }
|
||||
@else { <app-spinner /> }
|
||||
}
|
||||
@case ('error') {
|
||||
@case ('Failure') {
|
||||
@if (errorTpl()) {
|
||||
<ng-container [ngTemplateOutlet]="errorTpl()!.tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: resource().error(), retry: retry }" />
|
||||
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }" />
|
||||
} @else {
|
||||
<app-alert type="error">Er ging iets mis bij het laden van de gegevens.</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
@@ -51,11 +51,11 @@ type State = 'loading' | 'error' | 'empty' | 'loaded';
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case ('empty') {
|
||||
@case ('Empty') {
|
||||
@if (emptyTpl()) { <ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" /> }
|
||||
@else { <p class="rhc-paragraph">Geen gegevens gevonden.</p> }
|
||||
}
|
||||
@case ('loaded') {
|
||||
@case ('Success') {
|
||||
<ng-container [ngTemplateOutlet]="loadedTpl().tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: value() }" />
|
||||
}
|
||||
@@ -71,18 +71,17 @@ export class AsyncComponent<T> {
|
||||
emptyTpl = contentChild(AsyncEmptyDirective);
|
||||
errorTpl = contentChild(AsyncErrorDirective);
|
||||
|
||||
protected value = computed(() => {
|
||||
const r = this.resource();
|
||||
return r.hasValue() ? r.value() : undefined;
|
||||
});
|
||||
// Single source of truth: the resource projected into a RemoteData union.
|
||||
protected rd = computed(() => fromResource(this.resource(), this.isEmpty()));
|
||||
|
||||
protected state = computed<State>(() => {
|
||||
const r = this.resource();
|
||||
if (r.status() === 'error') return 'error';
|
||||
if (r.status() === 'loading') return 'loading';
|
||||
if (r.hasValue()) return this.isEmpty()(r.value()) ? 'empty' : 'loaded';
|
||||
return 'loading';
|
||||
});
|
||||
// value/error are pulled out via the exhaustive fold — only Success carries a
|
||||
// value, only Failure carries an error, so these can't lie.
|
||||
protected value = computed(() =>
|
||||
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: () => undefined, success: (v) => v }),
|
||||
);
|
||||
protected error = computed(() =>
|
||||
foldRemote(this.rd(), { loading: () => undefined, empty: () => undefined, failure: (e) => e, success: () => undefined }),
|
||||
);
|
||||
|
||||
retry = () => {
|
||||
const r = this.resource();
|
||||
|
||||
Reference in New Issue
Block a user