diff --git a/README.md b/README.md index c9be484..9a1a189 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,131 @@ -# BIG-register Self Service Portal — Atomic Design POC +# BIG-register Self-Service Portal — Atomic Design POC -An Angular POC showing how **atomic design** makes a frontend cheap to build and -reuse. Domain: the **BIG-register** self-service portal (NL register of healthcare -professionals). Styled with the **Rijkshuisstijl** via the NL Design System / -Utrecht component CSS — no hand-written theme. +A small Angular app that shows how **atomic design** makes a frontend cheap to build, +reuse and extend. The domain is the **BIG-register** self-service portal (the Dutch +register of healthcare professionals, run by CIBG). It looks like an NL Design System +app, branded **Rijkshuisstijl**, and demonstrates a robust **async-state pattern** where +the UI can never reach an inconsistent state. -## The atomic-design story (folder = layer) +> Demo / POC — no real data, no real login. Free **Fira Sans** stands in for the +> licensed Rijksoverheid font and a text wordmark for the logo. -``` -src/app/ - atoms/ button · text-input · heading · link · alert · status-badge - molecules/ form-field (label+input+error) · data-row - organisms/ site-header · site-footer · login-form · registration-summary - registration-table · change-request-form - templates/ page-layout (header + content + footer) - pages/ login · dashboard · registration-detail - core/ models · registration.service (HttpClient → public/mock/*.json) -``` +--- -**Reuse in action** — the same building blocks appear everywhere: -- `page-layout` wraps all three pages. -- `site-header` + `site-footer` render on every page. -- `form-field` + `text-input` + `button` are shared by the login form *and* the - change-request form. -- `status-badge` shows on both the dashboard and the detail page. - -Adding a new page is just composing existing organisms inside the layout — see -`pages/` for how little code each page is. - -## Styling / theming - -`@rijkshuisstijl-community/design-tokens` + `@rijkshuisstijl-community/components-css` -provide Rijkshuisstijl-themed Utrecht components as CSS. The theme is applied by the -`rhc-theme lintblauw` classes on `
` (see `src/index.html`). Atoms are thin -Angular wrappers that apply these CSS classes — so the design system does the visual -work and we only own the component API. - -> POC shortcuts: free **Fira Sans** stands in for the licensed Rijksoverheid font, a -> text wordmark stands in for the logo, login is mocked, and data is static JSON. - -## Run +## Run it ```bash npm install -npm start # ng serve → http://localhost:4200 (login → dashboard → detail) +npm start # app → http://localhost:4200 npm run storybook # component library, organized by atomic layer ``` + +Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie**. + +### See every data state (scenario toggle) + +Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state: + +| URL | What you see | +|-----|--------------| +| `/dashboard` | real data (fast) | +| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data | +| `/dashboard?scenario=loading` | the loading state, held open | +| `/dashboard?scenario=empty` | "geen gegevens" empty state | +| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) | + +--- + +## How atomic design works here (folder = layer) + +Atomic design organizes UI into five layers, each built from the one below. In this repo +the folder structure *is* the hierarchy (`src/app/`): + +| Layer | What it is | Examples here | +|-------|-----------|---------------| +| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` | +| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) | +| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` | +| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) | +| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` | + +Each atom is a thin Angular standalone component that applies the Utrecht/Rijkshuisstijl +CSS classes — so the design system does the visual work and we only own a small, typed +component API. + +--- + +## Where you actually notice the benefit + +**1. Reuse — the same blocks appear everywhere.** + +| Component | Appears in | +|-----------|-----------| +| `button` | login, change-request, herregistratie, async retry, Storybook | +| `form-field` + `text-input` | login form *and* change-request *and* herregistratie | +| `status-badge` | dashboard summary, detail summary | +| `page-shell` / `page-layout` | all four pages | +| `site-header` / `site-footer` | every page | +| `async` + `skeleton` | dashboard, detail | + +Change a component once and every screen that uses it updates. + +**2. A whole new page = composition, no new components.** +`pages/herregistratie/herregistratie.page.ts` is a complete new flow assembled entirely +from existing atoms/molecules/templates — zero new building blocks. That's the payoff: +new screens cost almost nothing. + +**3. Templates remove per-page boilerplate.** +Every page used to repeat its own back-link + heading + intro markup. `page-shell` +captures that once; pages now read like `Exposes signal-based resources (Angular's httpResource). Each returns a\nResource with status()/value()/error()/reload() — consumed by
Geen gegevens gevonden.
}\n }\n @case ('loaded') {\nGeen gegevens gevonden.
}\n }\n @case ('loaded') {\nGeen gegevens gevonden.
}\n }\n @case ('loaded') {\nGeen gegevens gevonden.
}\n }\n @case ('loaded') {\nGeen gegevens gevonden.
}\n }\n @case ('loaded') {\nRenders exactly ONE of loading / empty / error / loaded for a signal-based\nresource (e.g. httpResource). The states are mutually exclusive by\nconstruction, so the UI can never show two at once ("impossible states").\nUnprovided slots fall back to sensible defaults.
\n", + "rawdescription": "\n\nRenders exactly ONE of loading / empty / error / loaded for a signal-based\nresource (e.g. httpResource). The states are mutually exclusive by\nconstruction, so the UI can never show two at once (\"impossible states\").\nUnprovided slots fall back to sensible defaults.\n", + "type": "component", + "sourceCode": "import { Component, Directive, TemplateRef, computed, contentChild, input } from '@angular/core';\nimport { NgTemplateOutlet } from '@angular/common';\nimport type { Resource } from '@angular/core';\nimport { SpinnerComponent } from '../../atoms/spinner/spinner.component';\nimport { AlertComponent } from '../../atoms/alert/alert.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\n\n/* Slot markers. Put onGeen gegevens gevonden.
}\n }\n @case ('loaded') {\n\n
U heeft nog geen specialismen of aantekeningen.
\n\n
\n
\n
U heeft nog geen specialismen of aantekeningen.
\n\n
\n
Atom: heading. Renders the right h1..h5 with RHC heading styling.
\n", - "rawdescription": "\nAtom: heading. Renders the right h1..h5 with RHC heading styling.", + "imports": [ + { + "name": "NgTemplateOutlet" + } + ], + "description": "Atom: heading. Renders the right h1..h5 with RHC heading styling.\nSingle
A whole new page built from existing building blocks — no new components.\nThis is the atomic-design payoff: a new flow is just composition.
\n", + "rawdescription": "\nA whole new page built from existing building blocks — no new components.\nThis is the atomic-design payoff: a new flow is just composition.", + "type": "component", + "sourceCode": "import { Component, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { PageShellComponent } from '../../templates/page-shell/page-shell.component';\nimport { AlertComponent } from '../../atoms/alert/alert.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\nimport { FormFieldComponent } from '../../molecules/form-field/form-field.component';\nimport { TextInputComponent } from '../../atoms/text-input/text-input.component';\n\n/** A whole new page built from existing building blocks — no new components.\n This is the atomic-design payoff: a new flow is just composition. */\n@Component({\n selector: 'app-herregistratie-page',\n imports: [\n FormsModule, PageShellComponent, AlertComponent,\n ButtonComponent, FormFieldComponent, TextInputComponent,\n ],\n template: `\nOrganism: DigiD-style mock login. No real auth — just composes atoms/molecules.
\n", "rawdescription": "\nOrganism: DigiD-style mock login. No real auth — just composes atoms/molecules.", "type": "component", - "sourceCode": "import { Component, output } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { FormFieldComponent } from '../../molecules/form-field/form-field.component';\nimport { TextInputComponent } from '../../atoms/text-input/text-input.component';\nimport { ButtonComponent } from '../../atoms/button/button.component';\nimport { HeadingComponent } from '../../atoms/heading/heading.component';\n\n/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */\n@Component({\n selector: 'app-login-form',\n imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, HeadingComponent],\n template: `\n \n `,\n})\nexport class LoginFormComponent {\n bsn = '';\n password = '';\n submit = outputTemplate: header + centered content + footer. Wraps every page.
\n", - "rawdescription": "\nTemplate: header + centered content + footer. Wraps every page.", + "description": "Template: full-bleed header + footer with a centered content column. Wraps\nevery page. The colored bars span the viewport; content lines up via the\nshared --app-content-max width.
\n", + "rawdescription": "\nTemplate: full-bleed header + footer with a centered content column. Wraps\nevery page. The colored bars span the viewport; content lines up via the\nshared --app-content-max width.", "type": "component", - "sourceCode": "import { Component } from '@angular/core';\nimport { SiteHeaderComponent } from '../../organisms/site-header/site-header.component';\nimport { SiteFooterComponent } from '../../organisms/site-footer/site-footer.component';\n\n/** Template: header + centered content + footer. Wraps every page. */\n@Component({\n selector: 'app-page-layout',\n imports: [SiteHeaderComponent, SiteFooterComponent],\n template: `\n Naar de inhoud\n{{ intro() }}
\n }\nTemplate: standard page body inside the chrome — optional back-link, a\nheading, optional intro, and projected content. Every content page plugs\ninto this, so the heading/back-link markup lives in one place.
\n", + "rawdescription": "\nTemplate: standard page body inside the chrome — optional back-link, a\nheading, optional intro, and projected content. Every content page plugs\ninto this, so the heading/back-link markup lives in one place.", + "type": "component", + "sourceCode": "import { Component, input } from '@angular/core';\nimport { PageLayoutComponent } from '../page-layout/page-layout.component';\nimport { HeadingComponent } from '../../atoms/heading/heading.component';\nimport { LinkComponent } from '../../atoms/link/link.component';\n\n/** Template: standard page body inside the chrome — optional back-link, a\n heading, optional intro, and projected content. Every content page plugs\n into this, so the heading/back-link markup lives in one place. */\n@Component({\n selector: 'app-page-shell',\n imports: [PageLayoutComponent, HeadingComponent, LinkComponent],\n styles: [':host{display:block}'],\n template: `\n{{ intro() }}
\n }\nOrganism: site footer.
\n", "rawdescription": "\nOrganism: site footer.", "type": "component", - "sourceCode": "import { Component } from '@angular/core';\n\n/** Organism: site footer. */\n@Component({\n selector: 'app-site-footer',\n template: `\n \n `,\n})\nexport class SiteFooterComponent {}\n", + "sourceCode": "import { Component } from '@angular/core';\n\n/** Organism: site footer. */\n@Component({\n selector: 'app-site-footer',\n styles: [':host{display:block}'],\n template: `\n \n `,\n})\nexport class SiteFooterComponent {}\n", "assetsDirs": [], "styleUrlsData": "", - "stylesData": "", + "stylesData": ":host{display:block}\n", "extends": [] }, { "name": "SiteHeaderComponent", - "id": "component-SiteHeaderComponent-868e8e5af92fdf7923a2d989c5dcc13b193e2e09e186d8197937817cabc5bd13a676c11db35f4738ea9167b4425ad1fea6e7bd0b5f798983202b7bb99a37734a", + "id": "component-SiteHeaderComponent-710f1c109f26ae5f1888aa77ac682c58c9534fbba74aeae04b903e169f674c1ce04b8ae9e379a936c8ddef16e53c500e93d132021bcdd4ef743a601e05808f66", "file": "src/app/organisms/site-header/site-header.component.ts", "encapsulation": [], "entryComponents": [], @@ -1270,8 +1937,10 @@ "providers": [], "selector": "app-site-header", "styleUrls": [], - "styles": [], - "template": "Organism: site header with Rijksoverheid-style wordmark + title.\nponytail: text wordmark instead of the licensed Rijksoverheid logo.
\n", "rawdescription": "\nOrganism: site header with Rijksoverheid-style wordmark + title.\nponytail: text wordmark instead of the licensed Rijksoverheid logo.", "type": "component", - "sourceCode": "import { Component, input } from '@angular/core';\nimport { RouterLink } from '@angular/router';\n\n/** Organism: site header with Rijksoverheid-style wordmark + title.\n ponytail: text wordmark instead of the licensed Rijksoverheid logo. */\n@Component({\n selector: 'app-site-header',\n imports: [RouterLink],\n template: `\nAtom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes\non fast responses. Render count lines shaped roughly like the content.
Atom: spinner that only appears after delay ms — fast responses never\nflash a spinner, slow ones get feedback.
Atom: status badge = colored RHC dot-badge + label. Hand-built to show how\nyou compose a new atom from design tokens (dot color driven per status).
\n", "rawdescription": "\nAtom: status badge = colored RHC dot-badge + label. Hand-built to show how\nyou compose a new atom from design tokens (dot color driven per status).", "type": "component", - "sourceCode": "import { Component, computed, input } from '@angular/core';\nimport { RegistrationStatus } from '../../core/models';\n\n/** Atom: status badge = colored RHC dot-badge + label. Hand-built to show how\n you compose a new atom from design tokens (dot color driven per status). */\n@Component({\n selector: 'app-status-badge',\n template: `\n \n \n {{ status() }}\n \n `,\n})\nexport class StatusBadgeComponent {\n status = input.requiredConvenience: import this array to get the wrapper + all slot directives.
\n" }, { "name": "routes", @@ -1707,10 +2639,44 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('./pages/login/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', loadComponent: () => \"import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', loadComponent: () => \"import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: '**', redirectTo: 'login' },\n]" + "defaultValue": "[\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('./pages/login/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', loadComponent: () => \"import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', loadComponent: () => \"import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: 'herregistratie', loadComponent: () => \"import('./pages/herregistratie/herregistratie.page').then(m => m.HerregistratiePage)\" },\n { path: '**', redirectTo: 'login' },\n]" + }, + { + "name": "scenarioInterceptor", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/core/scenario.interceptor.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "HttpInterceptorFn", + "defaultValue": "(req, next) => {\n if (!req.url.includes('mock/')) return next(req);\n\n switch (currentScenario()) {\n case 'slow':\n return next(req).pipe(delay(2500));\n case 'loading':\n return next(req).pipe(delay(600_000)); // effectively never resolves\n case 'empty':\n return of(new HttpResponse({ status: 200, body: [] })).pipe(delay(400));\n case 'error':\n return timer(400).pipe(\n switchMap(() => throwError(() =>\n new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }))),\n );\n default:\n return next(req);\n }\n}", + "rawdescription": "Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.", + "description": "Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.
\n" + }, + { + "name": "VALID", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/core/scenario.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "Scenario[]", + "defaultValue": "['default', 'slow', 'loading', 'empty', 'error']" + } + ], + "functions": [ + { + "name": "currentScenario", + "file": "src/app/core/scenario.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "Reads ?scenario= from the URL so a demo can force each async state.
\n", + "args": [], + "returnType": "Scenario" } ], - "functions": [], "typealiases": [ { "name": "AlertType", @@ -1734,6 +2700,28 @@ "description": "", "kind": 193 }, + { + "name": "Scenario", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"default\" | \"slow\" | \"loading\" | \"empty\" | \"error\"", + "file": "src/app/core/scenario.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, + { + "name": "State", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"loading\" | \"error\" | \"empty\" | \"loaded\"", + "file": "src/app/molecules/async/async.component.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + }, { "name": "Variant", "ctype": "miscellaneous", @@ -1757,7 +2745,21 @@ "deprecated": false, "deprecationMessage": "", "type": "ApplicationConfig", - "defaultValue": "{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(routes),\n provideHttpClient(),\n { provide: LOCALE_ID, useValue: 'nl' },\n ]\n}" + "defaultValue": "{\n providers: [\n provideBrowserGlobalErrorListeners(),\n provideRouter(routes),\n provideHttpClient(withInterceptors([scenarioInterceptor])),\n { provide: LOCALE_ID, useValue: 'nl' },\n ]\n}" + } + ], + "src/app/molecules/async/async.component.ts": [ + { + "name": "ASYNC", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/molecules/async/async.component.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "unknown", + "defaultValue": "[\n AsyncComponent, AsyncLoadedDirective, AsyncLoadingDirective,\n AsyncEmptyDirective, AsyncErrorDirective,\n] as const", + "rawdescription": "Convenience: import this array to get the wrapper + all slot directives.", + "description": "Convenience: import this array to get the wrapper + all slot directives.
\n" } ], "src/app/app.routes.ts": [ @@ -1769,11 +2771,51 @@ "deprecated": false, "deprecationMessage": "", "type": "Routes", - "defaultValue": "[\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('./pages/login/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', loadComponent: () => \"import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', loadComponent: () => \"import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: '**', redirectTo: 'login' },\n]" + "defaultValue": "[\n { path: '', pathMatch: 'full', redirectTo: 'login' },\n { path: 'login', loadComponent: () => \"import('./pages/login/login.page').then(m => m.LoginPage)\" },\n { path: 'dashboard', loadComponent: () => \"import('./pages/dashboard/dashboard.page').then(m => m.DashboardPage)\" },\n { path: 'registratie', loadComponent: () => \"import('./pages/registration-detail/registration-detail.page').then(m => m.RegistrationDetailPage)\" },\n { path: 'herregistratie', loadComponent: () => \"import('./pages/herregistratie/herregistratie.page').then(m => m.HerregistratiePage)\" },\n { path: '**', redirectTo: 'login' },\n]" + } + ], + "src/app/core/scenario.interceptor.ts": [ + { + "name": "scenarioInterceptor", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/core/scenario.interceptor.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "HttpInterceptorFn", + "defaultValue": "(req, next) => {\n if (!req.url.includes('mock/')) return next(req);\n\n switch (currentScenario()) {\n case 'slow':\n return next(req).pipe(delay(2500));\n case 'loading':\n return next(req).pipe(delay(600_000)); // effectively never resolves\n case 'empty':\n return of(new HttpResponse({ status: 200, body: [] })).pipe(delay(400));\n case 'error':\n return timer(400).pipe(\n switchMap(() => throwError(() =>\n new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }))),\n );\n default:\n return next(req);\n }\n}", + "rawdescription": "Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.", + "description": "Demo-only: rewrites the timing/outcome of mock data requests based on\n?scenario= so loading / empty / error states can be shown on demand.\nReal requests are untouched.
\n" + } + ], + "src/app/core/scenario.ts": [ + { + "name": "VALID", + "ctype": "miscellaneous", + "subtype": "variable", + "file": "src/app/core/scenario.ts", + "deprecated": false, + "deprecationMessage": "", + "type": "Scenario[]", + "defaultValue": "['default', 'slow', 'loading', 'empty', 'error']" + } + ] + }, + "groupedFunctions": { + "src/app/core/scenario.ts": [ + { + "name": "currentScenario", + "file": "src/app/core/scenario.ts", + "ctype": "miscellaneous", + "subtype": "function", + "deprecated": false, + "deprecationMessage": "", + "description": "Reads ?scenario= from the URL so a demo can force each async state.
\n", + "args": [], + "returnType": "Scenario" } ] }, - "groupedFunctions": {}, "groupedEnumerations": {}, "groupedTypeAliases": { "src/app/atoms/alert/alert.component.ts": [ @@ -1802,6 +2844,32 @@ "kind": 193 } ], + "src/app/core/scenario.ts": [ + { + "name": "Scenario", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"default\" | \"slow\" | \"loading\" | \"empty\" | \"error\"", + "file": "src/app/core/scenario.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + } + ], + "src/app/molecules/async/async.component.ts": [ + { + "name": "State", + "ctype": "miscellaneous", + "subtype": "typealias", + "rawtype": "\"loading\" | \"error\" | \"empty\" | \"loaded\"", + "file": "src/app/molecules/async/async.component.ts", + "deprecated": false, + "deprecationMessage": "", + "description": "", + "kind": 193 + } + ], "src/app/atoms/button/button.component.ts": [ { "name": "Variant", @@ -1836,6 +2904,11 @@ "kind": "route-path", "filename": "src/app/app.routes.ts" }, + { + "name": "herregistratie", + "kind": "route-path", + "filename": "src/app/app.routes.ts" + }, { "name": "**", "kind": "route-path", @@ -1854,7 +2927,7 @@ ] }, "coverage": { - "count": 24, + "count": 25, "status": "low", "files": [ { @@ -1942,6 +3015,24 @@ "coverageCount": "1/2", "status": "medium" }, + { + "filePath": "src/app/atoms/skeleton/skeleton.component.ts", + "type": "component", + "linktype": "component", + "name": "SkeletonComponent", + "coveragePercent": 10, + "coverageCount": "1/10", + "status": "low" + }, + { + "filePath": "src/app/atoms/spinner/spinner.component.ts", + "type": "component", + "linktype": "component", + "name": "SpinnerComponent", + "coveragePercent": 16, + "coverageCount": "1/6", + "status": "low" + }, { "filePath": "src/app/atoms/status-badge/status-badge.component.ts", "type": "component", @@ -1993,8 +3084,113 @@ "type": "injectable", "linktype": "injectable", "name": "RegistrationService", + "coveragePercent": 33, + "coverageCount": "1/3", + "status": "medium" + }, + { + "filePath": "src/app/core/scenario.interceptor.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "scenarioInterceptor", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/core/scenario.ts", + "type": "function", + "linktype": "miscellaneous", + "linksubtype": "function", + "name": "currentScenario", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/core/scenario.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "VALID", "coveragePercent": 0, - "coverageCount": "0/4", + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/core/scenario.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "Scenario", + "coveragePercent": 0, + "coverageCount": "0/1", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "component", + "linktype": "component", + "name": "AsyncComponent", + "coveragePercent": 10, + "coverageCount": "1/10", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "directive", + "linktype": "directive", + "name": "AsyncEmptyDirective", + "coveragePercent": 0, + "coverageCount": "0/3", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "directive", + "linktype": "directive", + "name": "AsyncErrorDirective", + "coveragePercent": 0, + "coverageCount": "0/3", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "directive", + "linktype": "directive", + "name": "AsyncLoadedDirective", + "coveragePercent": 0, + "coverageCount": "0/3", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "directive", + "linktype": "directive", + "name": "AsyncLoadingDirective", + "coveragePercent": 0, + "coverageCount": "0/3", + "status": "low" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "variable", + "linktype": "miscellaneous", + "linksubtype": "variable", + "name": "ASYNC", + "coveragePercent": 100, + "coverageCount": "1/1", + "status": "very-good" + }, + { + "filePath": "src/app/molecules/async/async.component.ts", + "type": "type alias", + "linktype": "miscellaneous", + "linksubtype": "typealias", + "name": "State", + "coveragePercent": 0, + "coverageCount": "0/1", "status": "low" }, { @@ -2075,7 +3271,16 @@ "linktype": "component", "name": "DashboardPage", "coveragePercent": 0, - "coverageCount": "0/4", + "coverageCount": "0/6", + "status": "low" + }, + { + "filePath": "src/app/pages/herregistratie/herregistratie.page.ts", + "type": "component", + "linktype": "component", + "name": "HerregistratiePage", + "coveragePercent": 16, + "coverageCount": "1/6", "status": "low" }, { @@ -2093,7 +3298,7 @@ "linktype": "component", "name": "RegistrationDetailPage", "coveragePercent": 0, - "coverageCount": "0/4", + "coverageCount": "0/5", "status": "low" }, { @@ -2104,6 +3309,15 @@ "coveragePercent": 100, "coverageCount": "1/1", "status": "very-good" + }, + { + "filePath": "src/app/templates/page-shell/page-shell.component.ts", + "type": "component", + "linktype": "component", + "name": "PageShellComponent", + "coveragePercent": 16, + "coverageCount": "1/6", + "status": "low" } ] } diff --git a/src/app/app.config.ts b/src/app/app.config.ts index f8c6004..af4dfdb 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,10 +1,11 @@ import { ApplicationConfig, LOCALE_ID, provideBrowserGlobalErrorListeners } from '@angular/core'; import { provideRouter } from '@angular/router'; -import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { registerLocaleData } from '@angular/common'; import localeNl from '@angular/common/locales/nl'; import { routes } from './app.routes'; +import { scenarioInterceptor } from './core/scenario.interceptor'; registerLocaleData(localeNl); @@ -12,7 +13,7 @@ export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideRouter(routes), - provideHttpClient(), + provideHttpClient(withInterceptors([scenarioInterceptor])), { provide: LOCALE_ID, useValue: 'nl' }, ] }; diff --git a/src/app/atoms/skeleton/skeleton.component.ts b/src/app/atoms/skeleton/skeleton.component.ts new file mode 100644 index 0000000..a85fcda --- /dev/null +++ b/src/app/atoms/skeleton/skeleton.component.ts @@ -0,0 +1,33 @@ +import { Component, OnDestroy, OnInit, computed, input, signal } from '@angular/core'; + +/** Atom: skeleton placeholder (grey shimmer). Delay-gated so it never flashes + on fast responses. Render `count` lines shaped roughly like the content. */ +@Component({ + selector: 'app-skeleton', + styles: [` + :host{display:block} + .sk{background:linear-gradient(90deg,#e8ebee 25%,#f3f5f6 37%,#e8ebee 63%); + background-size:400% 100%;animation:sh 1.4s ease infinite;border-radius:4px; + margin-block-end:0.6rem} + @keyframes sh{0%{background-position:100% 0}100%{background-position:0 0}} + `], + template: ` + @if (visible()) { + @for (l of lines(); track $index) { + + } + } + `, +}) +export class SkeletonComponent implements OnInit, OnDestroy { + width = input('100%'); + height = input('1rem'); + count = input(1); + delay = input(150); + protected visible = signal(false); + protected lines = computed(() => Array(this.count()).fill(0)); + private timer?: ReturnTypeGeen gegevens gevonden.
} + } + @case ('loaded') { +Geen items gevonden.