Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
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 <ng-template> children of <app-async>. Generic so the
|
|
$implicit context is typed as the resource's T instead of unknown — see
|
|
AsyncComponent's contentChild<AsyncLoadedDirective<T>> below, which threads the
|
|
host's own T through the query result type. */
|
|
@Directive({ selector: '[appAsyncLoaded]' })
|
|
export class AsyncLoadedDirective<T = unknown> {
|
|
constructor(public tpl: TemplateRef<{ $implicit: T }>) {}
|
|
|
|
static ngTemplateContextGuard<T>(
|
|
_dir: AsyncLoadedDirective<T>,
|
|
_ctx: unknown,
|
|
): _ctx is { $implicit: T } {
|
|
return true;
|
|
}
|
|
}
|
|
@Directive({ selector: '[appAsyncLoading]' })
|
|
export class AsyncLoadingDirective {
|
|
constructor(public tpl: TemplateRef<unknown>) {}
|
|
}
|
|
@Directive({ selector: '[appAsyncEmpty]' })
|
|
export class AsyncEmptyDirective {
|
|
constructor(public tpl: TemplateRef<unknown>) {}
|
|
}
|
|
@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: `
|
|
<div aria-live="polite" [attr.aria-busy]="rd().tag === 'Loading' ? 'true' : null">
|
|
@switch (rd().tag) {
|
|
@case ('Loading') {
|
|
@if (loadingTpl()) {
|
|
<ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" />
|
|
} @else {
|
|
<app-spinner />
|
|
}
|
|
}
|
|
@case ('Failure') {
|
|
@if (errorTpl()) {
|
|
<ng-container
|
|
[ngTemplateOutlet]="errorTpl()!.tpl"
|
|
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }"
|
|
/>
|
|
} @else {
|
|
<app-alert type="error">{{ errorText() }}</app-alert>
|
|
<div style="margin-top:1rem">
|
|
<app-button variant="secondary" (click)="retry()">{{ retryText() }}</app-button>
|
|
</div>
|
|
}
|
|
}
|
|
@case ('Empty') {
|
|
@if (emptyTpl()) {
|
|
<ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" />
|
|
} @else {
|
|
<p>{{ emptyText() }}</p>
|
|
}
|
|
}
|
|
@case ('Success') {
|
|
<ng-container
|
|
[ngTemplateOutlet]="loadedTpl().tpl"
|
|
[ngTemplateOutletContext]="{ $implicit: value() }"
|
|
/>
|
|
}
|
|
}
|
|
</div>
|
|
`,
|
|
})
|
|
export class AsyncComponent<T> {
|
|
// 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<Resource<T>>();
|
|
data = input<RemoteData<Error | undefined, T>>();
|
|
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<T>>(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<RemoteData<Error | undefined, T>>(() => {
|
|
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<void>();
|
|
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;
|