import { Component, Directive, TemplateRef, computed, contentChild, input, output, } from '@angular/core'; import { NgTemplateOutlet } from '@angular/common'; import type { Resource } from '@angular/core'; 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 { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data'; /* Slot markers. Put on children of . Generic so the $implicit context is typed as the resource's T instead of unknown — see AsyncComponent's contentChild> below, which threads the host's own T through the query result type. */ @Directive({ selector: '[appAsyncLoaded]' }) export class AsyncLoadedDirective { constructor(public tpl: TemplateRef<{ $implicit: T }>) {} static ngTemplateContextGuard( _dir: AsyncLoadedDirective, _ctx: unknown, ): _ctx is { $implicit: T } { return true; } } @Directive({ selector: '[appAsyncLoading]' }) export class AsyncLoadingDirective { constructor(public tpl: TemplateRef) {} } @Directive({ selector: '[appAsyncEmpty]' }) export class AsyncEmptyDirective { constructor(public tpl: TemplateRef) {} } @Directive({ selector: '[appAsyncError]' }) export class AsyncErrorDirective { constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {} } /** * Renders exactly ONE of loading / empty / error / loaded for a signal-based * 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 (rd().tag) { @case ('Loading') { @if (loadingTpl()) { } @else { } } @case ('Failure') { @if (errorTpl()) { } @else { {{ errorText() }}
{{ retryText() }}
} } @case ('Empty') { @if (emptyTpl()) { } @else {

{{ emptyText() }}

} } @case ('Success') { } }
`, }) export class AsyncComponent { // Two ways to feed this component: // [resource] — a raw httpResource (the common case), or // [data] — an already-combined RemoteData (e.g. from a store via map2). resource = input>(); data = input>(); isEmpty = input<(v: T) => boolean>(() => false); // Shared/English component: copy lives behind language-agnostic inputs. Defaults are // localizable via $localize (source = default locale, currently nl); callers may override. errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`); retryText = input($localize`:@@async.retry:Opnieuw proberen`); emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`); loadedTpl = contentChild.required>(AsyncLoadedDirective); loadingTpl = contentChild(AsyncLoadingDirective); emptyTpl = contentChild(AsyncEmptyDirective); errorTpl = contentChild(AsyncErrorDirective); // Single source of truth: the supplied RemoteData, or the resource projected into one. protected rd = computed>(() => { const data = this.data(); if (data) return data; const r = this.resource(); return r ? fromResource(r, this.isEmpty()) : { tag: '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, }), ); // [resource]-fed callers get reload() for free. [data]-fed callers (a store's // combined RemoteData — the component doesn't own that resource) must reload // it themselves; retryClicked is how they find out a retry was requested. retryClicked = output(); retry = () => { const r = this.resource(); if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') { (r as { reload: () => void }).reload(); } this.retryClicked.emit(); }; } /** Convenience: import this array to get the wrapper + all slot directives. */ export const ASYNC = [ AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective, AsyncEmptyDirective, AsyncErrorDirective, ] as const;