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,97 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { EMPTY, filter } from 'rxjs';
|
||||
import { Locale, localeLinks } from './locale-links';
|
||||
|
||||
// CIBG-GAP EXTENSION: "Taal instellen" (designsystem.cibg.nl/componenten/taal-instellen) — no
|
||||
// vendored Huisstijl class ships for it, so this is a small hand-rolled surface built from the
|
||||
// token bridge. See cibg-gaps.mdx.
|
||||
/**
|
||||
* Organism: CIBG "Taal instellen" language switcher. A `<nav>` region (screenreader heading +
|
||||
* aria-label) with one link per locale — the endonym, tagged with its `lang`/`hreflang`, the
|
||||
* active one marked `aria-current` and rendered as text (not a link).
|
||||
*
|
||||
* Compile-time $localize means each locale is a separate bundle under `/<locale>/`, so switching
|
||||
* is a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale
|
||||
* is read from the baked `<base href>` (`/en/` → en, else nl) — the deployment truth, independent
|
||||
* of the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the
|
||||
* localized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).
|
||||
*
|
||||
* The shell (and this switcher within it) is a persistent parent — only the routed child
|
||||
* swaps — so `location.pathname` must be re-read on every completed navigation (same
|
||||
* `toSignal(router.events...)` idiom as `site-header.component.ts`'s breadcrumb `url`), or the
|
||||
* target link freezes at whichever route was active when the switcher was first constructed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-language-switcher',
|
||||
styles: [
|
||||
`
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--rhc-space-max-md);
|
||||
padding: var(--rhc-space-max-sm) var(--rhc-space-max-2xl);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
}
|
||||
a {
|
||||
color: var(--rhc-color-hemelblauw-700);
|
||||
}
|
||||
[aria-current] {
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<nav [attr.aria-label]="navLabel">
|
||||
<h2 class="sr-only">{{ heading }}</h2>
|
||||
@for (l of links(); track l.locale) {
|
||||
@if (l.active) {
|
||||
<span [attr.lang]="l.locale" aria-current="true">{{ l.label }}</span>
|
||||
} @else {
|
||||
<a [attr.lang]="l.locale" [attr.hreflang]="l.locale" [href]="l.href">{{ l.label }}</a>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class LanguageSwitcherComponent {
|
||||
/** Override the detected locale (stories/tests); the app detects it from the base href. */
|
||||
activeLocale = input<Locale | undefined>(undefined);
|
||||
|
||||
private readonly detected: Locale = /\/en\//.test(document.baseURI) ? 'en' : 'nl';
|
||||
private readonly loc =
|
||||
typeof location !== 'undefined'
|
||||
? location
|
||||
: ({ pathname: '/', search: '', hash: '' } as Location);
|
||||
|
||||
private router = inject(Router, { optional: true });
|
||||
private nav = toSignal(
|
||||
this.router?.events.pipe(filter((e) => e instanceof NavigationEnd)) ?? EMPTY,
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
protected links = computed(() => {
|
||||
this.nav(); // recompute on every completed navigation — loc.pathname is read fresh below
|
||||
return localeLinks(
|
||||
this.loc.pathname,
|
||||
this.activeLocale() ?? this.detected,
|
||||
this.loc.search,
|
||||
this.loc.hash,
|
||||
);
|
||||
});
|
||||
|
||||
protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;
|
||||
protected heading = $localize`:@@lang.heading:Kies een taal`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LanguageSwitcherComponent } from './language-switcher.component';
|
||||
|
||||
const meta: Meta<LanguageSwitcherComponent> = {
|
||||
title: 'Design System/Organisms/Language Switcher',
|
||||
component: LanguageSwitcherComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LanguageSwitcherComponent>;
|
||||
|
||||
/** Dutch active (the source locale). */
|
||||
export const NederlandsActive: Story = { args: { activeLocale: 'nl' } };
|
||||
|
||||
/** English active. */
|
||||
export const EnglishActive: Story = { args: { activeLocale: 'en' } };
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { localeLinks } from './locale-links';
|
||||
|
||||
describe('localeLinks (nl at root, en under /en/)', () => {
|
||||
it('an nl route (no prefix) links nl to the bare path, en under /en, marks active', () => {
|
||||
const links = localeLinks('/dashboard', 'nl');
|
||||
expect(links.map((l) => [l.locale, l.href, l.active])).toEqual([
|
||||
['nl', '/dashboard', true],
|
||||
['en', '/en/dashboard', false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('an en route strips the /en prefix for the nl target (deep path, en active)', () => {
|
||||
const links = localeLinks('/en/beheer/audit', 'en');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/beheer/audit');
|
||||
expect(links.find((l) => l.locale === 'en')!.active).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps query + hash on both targets', () => {
|
||||
const links = localeLinks('/registreren', 'nl', '?scenario=slow', '#top');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/registreren?scenario=slow#top');
|
||||
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/registreren?scenario=slow#top');
|
||||
});
|
||||
|
||||
it('the root maps nl → / and en → /en/', () => {
|
||||
const links = localeLinks('/', 'nl');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/');
|
||||
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/** The app's two locales (Angular $localize: source `nl` + translation `en`). */
|
||||
export type Locale = 'nl' | 'en';
|
||||
|
||||
export interface LocaleLink {
|
||||
readonly locale: Locale;
|
||||
/** Endonym — each language named in its own language (CIBG "Taal instellen"), not a code. */
|
||||
readonly label: string;
|
||||
/** Absolute path into the other locale's bundle, preserving the current route. */
|
||||
readonly href: string;
|
||||
readonly active: boolean;
|
||||
}
|
||||
|
||||
const LOCALES: readonly { locale: Locale; label: string }[] = [
|
||||
{ locale: 'nl', label: 'Nederlands' },
|
||||
{ locale: 'en', label: 'English' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the two language links for the switcher. Compile-time i18n serves the source locale
|
||||
* (nl) at the ROOT (`subPath: ''`) and en under `/en/`, so switching is a full navigation to the
|
||||
* sibling bundle at the same route. Strips a leading `/en` from the current path, then targets nl
|
||||
* at the bare path and en under `/en`. Keeps query + hash. Pure — no DOM (the component passes
|
||||
* `location.*` in).
|
||||
*/
|
||||
export function localeLinks(
|
||||
pathname: string,
|
||||
active: Locale,
|
||||
search = '',
|
||||
hash = '',
|
||||
): LocaleLink[] {
|
||||
const rest = pathname.replace(/^\/en(?=\/|$)/, '') || '/';
|
||||
const href = (locale: Locale) => `${locale === 'en' ? `/en${rest}` : rest}${search}${hash}`;
|
||||
return LOCALES.map(({ locale, label }) => ({
|
||||
locale,
|
||||
label,
|
||||
href: href(locale),
|
||||
active: locale === active,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user