feat(WP-67): merge behandelportal into this repo as a monorepo
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>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user