diff --git a/src/app/core/remote-data.ts b/src/app/core/remote-data.ts new file mode 100644 index 0000000..d5e96e2 --- /dev/null +++ b/src/app/core/remote-data.ts @@ -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 = + | { 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( + r: Resource, + isEmpty: (v: T) => boolean = () => false, +): RemoteData { + 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( + rd: RemoteData, + 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); + } +} diff --git a/src/app/molecules/async/async.component.ts b/src/app/molecules/async/async.component.ts index ab7cac9..b92fe38 100644 --- a/src/app/molecules/async/async.component.ts +++ b/src/app/molecules/async/async.component.ts @@ -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 children of . */ @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()) { } @else { } } - @case ('error') { + @case ('Failure') { @if (errorTpl()) { + [ngTemplateOutletContext]="{ $implicit: error(), retry: retry }" /> } @else { Er ging iets mis bij het laden van de gegevens.
@@ -51,11 +51,11 @@ type State = 'loading' | 'error' | 'empty' | 'loaded';
} } - @case ('empty') { + @case ('Empty') { @if (emptyTpl()) { } @else {

Geen gegevens gevonden.

} } - @case ('loaded') { + @case ('Success') { } @@ -71,18 +71,17 @@ export class AsyncComponent { 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(() => { - 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();