From 927404afd7b7fafd040020210d666d87eefeb322 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 31 Jul 2026 23:27:09 +0200 Subject: [PATCH] feat: minimal signals + RemoteData template with a users feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted from atomic-design-poc: RemoteData (bare union + fromResource, no combinators) + a trimmed switch, no Elm-style store — state is resource() plus one plain signal. Minimal DDD layering per context (domain/infrastructure/application/ui) combined with atomic design inside ui/ (atoms/molecules/organisms/templates/pages), mirroring the POC's conventions at template scale. One worked feature (users/): a list with a click-through to a detail view (its own independent resource() fetch) and a Back action. Tests are BDD-style (describe/it, one assertion per it) and black-box (assert rendered DOM/emitted events only) - 100% branch/line coverage. README.md documents what's deliberately omitted vs. the POC and the concrete growth path back to it. Co-Authored-By: Claude Sonnet 5 --- README.md | 118 +++--- angular.json | 3 +- package-lock.json | 201 ++++++++++ package.json | 3 +- src/app/app.config.ts | 5 +- src/app/app.html | 344 +----------------- src/app/app.spec.ts | 23 +- src/app/app.ts | 11 +- .../shared/application/remote-data.spec.ts | 46 +++ src/app/shared/application/remote-data.ts | 24 ++ .../shared/ui/atoms/spinner.component.spec.ts | 13 + src/app/shared/ui/atoms/spinner.component.ts | 22 ++ .../ui/molecules/async.component.spec.ts | 60 +++ .../shared/ui/molecules/async.component.ts | 32 ++ .../ui/templates/page-shell.component.spec.ts | 13 + .../ui/templates/page-shell.component.ts | 12 + .../users/application/user-detail.resource.ts | 5 + src/app/users/application/users.resource.ts | 4 + src/app/users/domain/user.spec.ts | 16 + src/app/users/domain/user.ts | 15 + src/app/users/infrastructure/users.adapter.ts | 7 + .../organisms/user-detail.component.spec.ts | 42 +++ .../ui/organisms/user-detail.component.ts | 23 ++ .../ui/organisms/user-list.component.spec.ts | 27 ++ .../users/ui/organisms/user-list.component.ts | 19 + src/app/users/ui/users.page.spec.ts | 66 ++++ src/app/users/ui/users.page.ts | 31 ++ src/main.ts | 3 +- tsconfig.json | 8 +- 29 files changed, 778 insertions(+), 418 deletions(-) create mode 100644 src/app/shared/application/remote-data.spec.ts create mode 100644 src/app/shared/application/remote-data.ts create mode 100644 src/app/shared/ui/atoms/spinner.component.spec.ts create mode 100644 src/app/shared/ui/atoms/spinner.component.ts create mode 100644 src/app/shared/ui/molecules/async.component.spec.ts create mode 100644 src/app/shared/ui/molecules/async.component.ts create mode 100644 src/app/shared/ui/templates/page-shell.component.spec.ts create mode 100644 src/app/shared/ui/templates/page-shell.component.ts create mode 100644 src/app/users/application/user-detail.resource.ts create mode 100644 src/app/users/application/users.resource.ts create mode 100644 src/app/users/domain/user.spec.ts create mode 100644 src/app/users/domain/user.ts create mode 100644 src/app/users/infrastructure/users.adapter.ts create mode 100644 src/app/users/ui/organisms/user-detail.component.spec.ts create mode 100644 src/app/users/ui/organisms/user-detail.component.ts create mode 100644 src/app/users/ui/organisms/user-list.component.spec.ts create mode 100644 src/app/users/ui/organisms/user-list.component.ts create mode 100644 src/app/users/ui/users.page.spec.ts create mode 100644 src/app/users/ui/users.page.ts diff --git a/README.md b/README.md index eb5b965..d56c9ce 100644 --- a/README.md +++ b/README.md @@ -1,59 +1,89 @@ -# NgSignalsTemplate +# ng-signals-template -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.1.2. +A minimal Angular 22 starter: signals for state, `resource()` for async data, and a +`RemoteData` type that makes "loading but has an error," "success with no value," and +similar illegal combinations impossible to construct. One worked feature (`users/`) shows +the whole pattern end to end, including an action (click a user → see their details → +go back). -## Development server +It's extracted from a larger reference app — the "POC" referenced throughout this +document — which shows the same ideas grown up to production scale (multi-context DDD, +enforced architecture boundaries, i18n, a real design system, generated API clients). This +template deliberately keeps only the part of that setup useful from day one, and documents +exactly where to reach for the rest as a project grows. -To start a local development server, run: +## Running it ```bash -ng serve +npm start # ng serve +npm test # ng test (Vitest) ``` -Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files. +## What's here -## Code scaffolding +- **`RemoteData`** (`shared/application/remote-data.ts`) — a 4-variant union + (`Loading | Empty | Failure | Success`) plus `fromResource()`, which projects Angular's + own `resource()` into one. No store, no reducer — `resource()` already holds the async + state; `RemoteData` just normalizes it for exhaustive rendering. +- **``** (`shared/ui/molecules/async.component.ts`) — a `@switch` over all 4 + states: a spinner while loading, an empty message, a failure message with a retry + button, or your projected content on success. Reused by both fetches in `users/`. +- **``** (`shared/ui/templates/page-shell.component.ts`) — a heading plus + one content slot. That's it. +- **`users/`** — one feature context, laid out the same way a bigger one would be: + `domain/` (pure types, no Angular import), `infrastructure/` (the only file allowed to + call `fetch`), `application/` (composes infrastructure + `resource()` — this is also + exactly where a real store would slot in later), `ui/` (organisms + the page). Clicking + a user in the list sets one plain `signal` on the page, which swaps in a + `UserDetailComponent` that does its own independent fetch. +- Path aliases `@shared/*` and `@users/*` (see `tsconfig.json`) instead of relative + `../../` imports, one per context — add one per new context you create. +- Tests are BDD-style (`describe`/`it`, one `expect` per `it`) and black-box: they assert + on rendered DOM and emitted events, never on a component's private fields, so a test + never breaks just because an internal was refactored. -Angular CLI includes powerful code scaffolding tools. To generate a new component, run: +## What's deliberately not here (vs. the POC) -```bash -ng generate component component-name -``` +| Missing | Why | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| Elm-style store (`createStore`/Model-Msg-reduce) | Not needed until a page's state has more than a couple of interacting fields — see Growth path below. | +| i18n (`$localize` + translation file) | POC-specific requirement (a Dutch app shipping English too); irrelevant for a single-locale starter. | +| CIBG Huisstijl theming / token bridge | The POC's specific design system; a starter has no house style to vendor yet. | +| `dependency-cruiser` boundary enforcement | Real value once you have 2+ contexts that must not import each other; overhead for one. | +| `contracts/` layer + generated API client + `parse*` boundary | Only earns its keep once you're consuming a real backend's OpenAPI contract, not a public test API. | +| Storybook + axe a11y gate | Testing/documentation infrastructure that pays off at a much bigger component count. | +| CI pipeline | Nothing to gate yet with one context and no deploy target. | -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: +## Growth path — when you outgrow this -```bash -ng generate --help -``` +Each of these is a real, working pattern in the POC — copy it when you actually need it, +not before: -## Building +- **A page's state grows past 2-3 interacting fields, or needs undo/multi-step flow** → + add an Elm-style store: `shared/application/store.ts` (`createStore`) + + a `*.machine.ts` per feature (Model/Msg/pure `reduce`). +- **You have 2+ contexts that must not import each other** → add + `dependency-cruiser` (`.dependency-cruiser.js`) to enforce the `domain → application → +infrastructure`/`ui` direction this template already follows by convention but doesn't + check. +- **You're consuming a real backend's OpenAPI contract** → add a `contracts/` layer + (wire DTOs) + a generated typed client + a hand-written `parse*` boundary in + `infrastructure/` (see ADR-0001, `.claude/skills/bff-endpoint/SKILL.md` if you're + working from the POC directly). +- **A second locale** → wrap user-facing copy in `$localize` with a stable custom id and + add a translation `.xlf` file (see the POC's `CLAUDE.md` "User-facing copy" convention). +- **A real design system** → vendor your CSS, then bridge your own token names onto it the + way ADR-0003 (`docs/reference/architecture/0003-cibg-huisstijl.md` in the POC) does — + keep your token names stable, only their values change. +- **Testing/a11y at real component count** → add Storybook + the axe a11y addon so every + component's states are visually verifiable and accessibility-checked, not just + behavior-tested. -To build the project run: +## Folder convention -```bash -ng build -``` - -This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed. - -## Running unit tests - -To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command: - -```bash -ng test -``` - -## Running end-to-end tests - -For end-to-end (e2e) testing, run: - -```bash -ng e2e -``` - -Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs. - -## Additional Resources - -For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page. +Each business capability is a context under `src/app//`, split into +`domain/application/infrastructure/ui` — dependencies point inward +(`ui → application → domain`; only `application` reaches `infrastructure`). Inside a +context's `ui/`, components are organized by atomic-design layer (`atoms → molecules → +organisms → templates → pages`); `shared/` holds only cross-context building blocks. When +you add a second context, mirror `users/`'s shape. diff --git a/angular.json b/angular.json index 383379f..46d6df7 100644 --- a/angular.json +++ b/angular.json @@ -2,7 +2,8 @@ "$schema": "./node_modules/@angular/cli/lib/config/schema.json", "version": 1, "cli": { - "packageManager": "npm" + "packageManager": "npm", + "analytics": false }, "newProjectRoot": "projects", "projects": { diff --git a/package-lock.json b/package-lock.json index d7ad67f..9a4df71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@angular/build": "^22.1.2", "@angular/cli": "^22.1.2", "@angular/compiler-cli": "^22.1.0", + "@vitest/coverage-v8": "^4.1.10", "jsdom": "^28.0.0", "prettier": "^3.8.1", "typescript": "~6.0.2", @@ -756,6 +757,16 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3472,6 +3483,37 @@ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -3723,6 +3765,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.9", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.9.tgz", @@ -4864,6 +4918,16 @@ "dev": true, "license": "ISC" }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4926,6 +4990,13 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", @@ -5169,6 +5240,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.6", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.6.tgz", @@ -5728,6 +5838,84 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/magicast/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/magicast/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/magicast/node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/magicast/node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6916,6 +7104,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/package.json b/package.json index e4d3d45..c790fbf 100644 --- a/package.json +++ b/package.json @@ -24,9 +24,10 @@ "@angular/build": "^22.1.2", "@angular/cli": "^22.1.2", "@angular/compiler-cli": "^22.1.0", + "@vitest/coverage-v8": "^4.1.10", "jsdom": "^28.0.0", "prettier": "^3.8.1", "typescript": "~6.0.2", "vitest": "^4.0.8" } -} \ No newline at end of file +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 7f244ed..45c753e 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,8 +1,5 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; export const appConfig: ApplicationConfig = { - providers: [ - provideBrowserGlobalErrorListeners(), - - ] + providers: [provideBrowserGlobalErrorListeners()], }; diff --git a/src/app/app.html b/src/app/app.html index 0edf11a..4dcec5d 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -1,343 +1 @@ - - - - - - - - - - - -
-
-
- -

Hello, {{ title() }}

-

Congratulations! Your app is running. 🎉

-
- -
-
- @for (item of [ - { title: 'Explore the Docs', link: 'https://angular.dev' }, - { title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' }, - { title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'}, - { title: 'CLI Docs', link: 'https://angular.dev/tools/cli' }, - { title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' }, - { title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' }, - ]; track item.title) { - - {{ item.title }} - - - - - } -
- -
-
-
- - - - - - - - - - + diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 9f51ef8..85085ab 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,23 +1,14 @@ +import { describe, expect, it, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { App } from './app'; describe('App', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [App], - }).compileComponents(); - }); - - it('should create the app', () => { + it('renders the users page heading', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve([]) })); + await TestBed.configureTestingModule({ imports: [App] }).compileComponents(); const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); - - it('should render title', async () => { - const fixture = TestBed.createComponent(App); - await fixture.whenStable(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('h1')?.textContent).toContain('Hello, ng-signals-template'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('h1')?.textContent).toBe('Users'); + vi.unstubAllGlobals(); }); }); diff --git a/src/app/app.ts b/src/app/app.ts index cf2bdd2..196265f 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,11 +1,10 @@ -import { Component, signal } from '@angular/core'; +import { Component } from '@angular/core'; +import { UsersPage } from '@users/ui/users.page'; @Component({ selector: 'app-root', - imports: [], + imports: [UsersPage], templateUrl: './app.html', - styleUrl: './app.css' + styleUrl: './app.css', }) -export class App { - protected readonly title = signal('ng-signals-template'); -} +export class App {} diff --git a/src/app/shared/application/remote-data.spec.ts b/src/app/shared/application/remote-data.spec.ts new file mode 100644 index 0000000..b13681f --- /dev/null +++ b/src/app/shared/application/remote-data.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@angular/core'; +import { fromResource } from './remote-data'; + +interface FakeResourceOverrides { + status?: () => string; + hasValue?: () => boolean; + value?: () => T; + error?: () => unknown; +} + +function fakeResource(overrides: FakeResourceOverrides): Resource { + return { + status: () => 'resolved', + hasValue: () => false, + value: () => undefined as T, + error: () => undefined, + ...overrides, + } as unknown as Resource; +} + +describe('fromResource', () => { + it('is Loading while the resource has not resolved', () => { + const rd = fromResource(fakeResource({ hasValue: () => false })); + expect(rd).toEqual({ tag: 'Loading' }); + }); + + it('is Failure when the resource has errored', () => { + const error = new Error('boom'); + const rd = fromResource(fakeResource({ status: () => 'error', error: () => error })); + expect(rd).toEqual({ tag: 'Failure', error }); + }); + + it('is Empty when the resolved value is considered empty', () => { + const rd = fromResource( + fakeResource({ hasValue: () => true, value: () => [] }), + (v) => v.length === 0, + ); + expect(rd).toEqual({ tag: 'Empty' }); + }); + + it('is Success with the value when resolved and not empty', () => { + const rd = fromResource(fakeResource({ hasValue: () => true, value: () => ['a'] })); + expect(rd).toEqual({ tag: 'Success', value: ['a'] }); + }); +}); diff --git a/src/app/shared/application/remote-data.ts b/src/app/shared/application/remote-data.ts new file mode 100644 index 0000000..3e1c4e3 --- /dev/null +++ b/src/app/shared/application/remote-data.ts @@ -0,0 +1,24 @@ +import type { Resource } from '@angular/core'; + +/** + * Four mutually exclusive async states — the data lives ON the state, so e.g. + * "success with no value" or "error with a stale value" is unrepresentable. + */ +export type RemoteData = + | { tag: 'Loading' } + | { tag: 'Empty' } + | { tag: 'Failure'; error: E } + | { tag: 'Success'; value: T }; + +/** Project Angular's own resource() into a RemoteData value. */ +export function fromResource( + r: Resource, + isEmpty: (v: T) => boolean = () => false, +): RemoteData { + if (r.status() === 'error') return { tag: 'Failure', error: r.error() }; + if (r.hasValue()) { + const v = r.value(); + return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v }; + } + return { tag: 'Loading' }; +} diff --git a/src/app/shared/ui/atoms/spinner.component.spec.ts b/src/app/shared/ui/atoms/spinner.component.spec.ts new file mode 100644 index 0000000..21d7b77 --- /dev/null +++ b/src/app/shared/ui/atoms/spinner.component.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { SpinnerComponent } from './spinner.component'; + +describe('SpinnerComponent', () => { + it('renders a status role element', async () => { + await TestBed.configureTestingModule({ imports: [SpinnerComponent] }).compileComponents(); + const fixture = TestBed.createComponent(SpinnerComponent); + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + expect(el.querySelector('[role="status"]')).toBeTruthy(); + }); +}); diff --git a/src/app/shared/ui/atoms/spinner.component.ts b/src/app/shared/ui/atoms/spinner.component.ts new file mode 100644 index 0000000..3867b7d --- /dev/null +++ b/src/app/shared/ui/atoms/spinner.component.ts @@ -0,0 +1,22 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-spinner', + template: `
`, + styles: ` + .spinner { + width: 1.5rem; + height: 1.5rem; + border: 2px solid #ddd; + border-top-color: #333; + border-radius: 50%; + animation: spin 0.6s linear infinite; + } + @keyframes spin { + to { + transform: rotate(360deg); + } + } + `, +}) +export class SpinnerComponent {} diff --git a/src/app/shared/ui/molecules/async.component.spec.ts b/src/app/shared/ui/molecules/async.component.spec.ts new file mode 100644 index 0000000..4a4292f --- /dev/null +++ b/src/app/shared/ui/molecules/async.component.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { AsyncComponent } from './async.component'; +import type { RemoteData } from '@shared/application/remote-data'; + +@Component({ + imports: [AsyncComponent], + template: ` + +

loaded content

+
+ `, +}) +class HostComponent { + data: RemoteData = { tag: 'Loading' }; + retried = false; +} + +function render(data: RemoteData) { + const fixture = TestBed.createComponent(HostComponent); + fixture.componentInstance.data = data; + fixture.detectChanges(); + return fixture; +} + +describe('AsyncComponent', () => { + it('shows a spinner while loading', async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents(); + const fixture = render({ tag: 'Loading' }); + expect(fixture.nativeElement.querySelector('app-spinner')).toBeTruthy(); + }); + + it('shows the empty text when empty', async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents(); + const fixture = render({ tag: 'Empty' }); + expect(fixture.nativeElement.textContent).toContain('No data.'); + }); + + it('shows the error text on failure', async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents(); + const fixture = render({ tag: 'Failure', error: new Error('boom') }); + expect(fixture.nativeElement.querySelector('[role="alert"]')?.textContent).toContain( + 'Something went wrong.', + ); + }); + + it('emits retry when the retry button is clicked', async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents(); + const fixture = render({ tag: 'Failure', error: new Error('boom') }); + fixture.nativeElement.querySelector('button').click(); + expect(fixture.componentInstance.retried).toBe(true); + }); + + it('projects success content when successful', async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents(); + const fixture = render({ tag: 'Success', value: 'x' }); + expect(fixture.nativeElement.querySelector('.content')).toBeTruthy(); + }); +}); diff --git a/src/app/shared/ui/molecules/async.component.ts b/src/app/shared/ui/molecules/async.component.ts new file mode 100644 index 0000000..57a01b8 --- /dev/null +++ b/src/app/shared/ui/molecules/async.component.ts @@ -0,0 +1,32 @@ +import { Component, input, output } from '@angular/core'; +import { SpinnerComponent } from '@shared/ui/atoms/spinner.component'; +import type { RemoteData } from '@shared/application/remote-data'; + +@Component({ + selector: 'app-async', + imports: [SpinnerComponent], + template: ` + @switch (data().tag) { + @case ('Loading') { + + } + @case ('Empty') { +

{{ emptyText() }}

+ } + @case ('Failure') { +

{{ errorText() }}

+ + } + @case ('Success') { + + } + } + `, +}) +export class AsyncComponent { + data = input.required>(); + emptyText = input('No data.'); + errorText = input('Something went wrong.'); + retryText = input('Retry'); + retry = output(); +} diff --git a/src/app/shared/ui/templates/page-shell.component.spec.ts b/src/app/shared/ui/templates/page-shell.component.spec.ts new file mode 100644 index 0000000..50f05b8 --- /dev/null +++ b/src/app/shared/ui/templates/page-shell.component.spec.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { PageShellComponent } from './page-shell.component'; + +describe('PageShellComponent', () => { + it('renders the heading', async () => { + await TestBed.configureTestingModule({ imports: [PageShellComponent] }).compileComponents(); + const fixture = TestBed.createComponent(PageShellComponent); + fixture.componentRef.setInput('heading', 'Users'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('h1')?.textContent).toBe('Users'); + }); +}); diff --git a/src/app/shared/ui/templates/page-shell.component.ts b/src/app/shared/ui/templates/page-shell.component.ts new file mode 100644 index 0000000..acbf968 --- /dev/null +++ b/src/app/shared/ui/templates/page-shell.component.ts @@ -0,0 +1,12 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'app-page-shell', + template: ` +

{{ heading() }}

+ + `, +}) +export class PageShellComponent { + heading = input.required(); +} diff --git a/src/app/users/application/user-detail.resource.ts b/src/app/users/application/user-detail.resource.ts new file mode 100644 index 0000000..00e90e7 --- /dev/null +++ b/src/app/users/application/user-detail.resource.ts @@ -0,0 +1,5 @@ +import { resource } from '@angular/core'; +import { fetchUserById } from '@users/infrastructure/users.adapter'; + +export const userDetailResource = (userId: () => number) => + resource({ params: userId, loader: ({ params }) => fetchUserById(params) }); diff --git a/src/app/users/application/users.resource.ts b/src/app/users/application/users.resource.ts new file mode 100644 index 0000000..69e1152 --- /dev/null +++ b/src/app/users/application/users.resource.ts @@ -0,0 +1,4 @@ +import { resource } from '@angular/core'; +import { fetchUsers } from '@users/infrastructure/users.adapter'; + +export const usersResource = () => resource({ loader: fetchUsers }); diff --git a/src/app/users/domain/user.spec.ts b/src/app/users/domain/user.spec.ts new file mode 100644 index 0000000..c9136b2 --- /dev/null +++ b/src/app/users/domain/user.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { isEmptyUserList } from './user'; + +describe('isEmptyUserList', () => { + it('is empty for an empty list', () => { + expect(isEmptyUserList([])).toBe(true); + }); + + it('is not empty when a user is present', () => { + expect(isEmptyUserList([{ id: 1, name: 'Ada' }])).toBe(false); + }); + + it('is empty when the list is undefined', () => { + expect(isEmptyUserList(undefined)).toBe(true); + }); +}); diff --git a/src/app/users/domain/user.ts b/src/app/users/domain/user.ts new file mode 100644 index 0000000..5c934c8 --- /dev/null +++ b/src/app/users/domain/user.ts @@ -0,0 +1,15 @@ +export interface User { + id: number; + name: string; +} + +export interface UserDetail extends User { + email: string; +} + +/** + * Accepts `undefined` because a resource's value is `T | undefined` until it resolves + * (no `defaultValue` is set) — `fromResource` only calls this once `hasValue()` is true, + * but the type can't express that across the generic boundary. + */ +export const isEmptyUserList = (users: User[] | undefined): boolean => !users || users.length === 0; diff --git a/src/app/users/infrastructure/users.adapter.ts b/src/app/users/infrastructure/users.adapter.ts new file mode 100644 index 0000000..806a2c6 --- /dev/null +++ b/src/app/users/infrastructure/users.adapter.ts @@ -0,0 +1,7 @@ +import type { User, UserDetail } from '@users/domain/user'; + +export const fetchUsers = (): Promise => + fetch('https://jsonplaceholder.typicode.com/users').then((r) => r.json()); + +export const fetchUserById = (id: number): Promise => + fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((r) => r.json()); diff --git a/src/app/users/ui/organisms/user-detail.component.spec.ts b/src/app/users/ui/organisms/user-detail.component.spec.ts new file mode 100644 index 0000000..f73f4f2 --- /dev/null +++ b/src/app/users/ui/organisms/user-detail.component.spec.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { UserDetailComponent } from './user-detail.component'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('UserDetailComponent', () => { + it("shows the fetched user's name and email once resolved", async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }), + }), + ); + await TestBed.configureTestingModule({ imports: [UserDetailComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UserDetailComponent); + fixture.componentRef.setInput('userId', 1); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('ada@example.com'); + }); + + it('emits close when the back button is clicked', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'a@x.com' }), + }), + ); + await TestBed.configureTestingModule({ imports: [UserDetailComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UserDetailComponent); + fixture.componentRef.setInput('userId', 1); + fixture.detectChanges(); + let closed = false; + fixture.componentInstance.close.subscribe(() => (closed = true)); + fixture.nativeElement.querySelectorAll('button')[0].click(); + expect(closed).toBe(true); + }); +}); diff --git a/src/app/users/ui/organisms/user-detail.component.ts b/src/app/users/ui/organisms/user-detail.component.ts new file mode 100644 index 0000000..c3fbbc0 --- /dev/null +++ b/src/app/users/ui/organisms/user-detail.component.ts @@ -0,0 +1,23 @@ +import { Component, computed, input, output } from '@angular/core'; +import { AsyncComponent } from '@shared/ui/molecules/async.component'; +import { fromResource } from '@shared/application/remote-data'; +import { userDetailResource } from '@users/application/user-detail.resource'; + +@Component({ + selector: 'app-user-detail', + imports: [AsyncComponent], + template: ` + + @if (detailResource.value(); as u) { +

{{ u.name }} — {{ u.email }}

+ } +
+ + `, +}) +export class UserDetailComponent { + userId = input.required(); + close = output(); + protected detailResource = userDetailResource(() => this.userId()); + protected data = computed(() => fromResource(this.detailResource)); +} diff --git a/src/app/users/ui/organisms/user-list.component.spec.ts b/src/app/users/ui/organisms/user-list.component.spec.ts new file mode 100644 index 0000000..1f6c0b1 --- /dev/null +++ b/src/app/users/ui/organisms/user-list.component.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { UserListComponent } from './user-list.component'; + +describe('UserListComponent', () => { + it('renders one button per user', async () => { + await TestBed.configureTestingModule({ imports: [UserListComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UserListComponent); + fixture.componentRef.setInput('users', [ + { id: 1, name: 'Ada' }, + { id: 2, name: 'Grace' }, + ]); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelectorAll('button').length).toBe(2); + }); + + it("emits select with the clicked user's id", async () => { + await TestBed.configureTestingModule({ imports: [UserListComponent] }).compileComponents(); + const fixture = TestBed.createComponent(UserListComponent); + fixture.componentRef.setInput('users', [{ id: 7, name: 'Ada' }]); + fixture.detectChanges(); + let selected: number | undefined; + fixture.componentInstance.select.subscribe((id: number) => (selected = id)); + fixture.nativeElement.querySelector('button').click(); + expect(selected).toBe(7); + }); +}); diff --git a/src/app/users/ui/organisms/user-list.component.ts b/src/app/users/ui/organisms/user-list.component.ts new file mode 100644 index 0000000..0a71492 --- /dev/null +++ b/src/app/users/ui/organisms/user-list.component.ts @@ -0,0 +1,19 @@ +import { Component, input, output } from '@angular/core'; +import type { User } from '@users/domain/user'; + +@Component({ + selector: 'app-user-list', + template: ` +
    + @for (u of users(); track u.id) { +
  • + +
  • + } +
+ `, +}) +export class UserListComponent { + users = input.required(); + select = output(); +} diff --git a/src/app/users/ui/users.page.spec.ts b/src/app/users/ui/users.page.spec.ts new file mode 100644 index 0000000..f02ac9f --- /dev/null +++ b/src/app/users/ui/users.page.spec.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { UsersPage } from './users.page'; + +function stubFetch() { + vi.stubGlobal( + 'fetch', + vi.fn((url: string) => { + if (url.endsWith('/users')) { + return Promise.resolve({ json: () => Promise.resolve([{ id: 1, name: 'Ada' }]) }); + } + return Promise.resolve({ + json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }), + }); + }), + ); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('UsersPage', () => { + it('renders the fetched users as a list', async () => { + stubFetch(); + await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents(); + const fixture = TestBed.createComponent(UsersPage); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Ada'); + }); + + it("shows a user's details after clicking their name", async () => { + stubFetch(); + await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents(); + const fixture = TestBed.createComponent(UsersPage); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('ada@example.com'); + }); + + it('returns to the list when the detail view is closed', async () => { + stubFetch(); + await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents(); + const fixture = TestBed.createComponent(UsersPage); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + const backButton = Array.from(fixture.nativeElement.querySelectorAll('button')).find( + (b) => (b as HTMLElement).textContent === 'Back', + ) as HTMLElement; + backButton.click(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('app-user-detail')).toBeFalsy(); + }); +}); diff --git a/src/app/users/ui/users.page.ts b/src/app/users/ui/users.page.ts new file mode 100644 index 0000000..5a2de52 --- /dev/null +++ b/src/app/users/ui/users.page.ts @@ -0,0 +1,31 @@ +import { Component, computed, signal } from '@angular/core'; +import { PageShellComponent } from '@shared/ui/templates/page-shell.component'; +import { AsyncComponent } from '@shared/ui/molecules/async.component'; +import { fromResource } from '@shared/application/remote-data'; +import { UserListComponent } from '@users/ui/organisms/user-list.component'; +import { UserDetailComponent } from '@users/ui/organisms/user-detail.component'; +import { usersResource } from '@users/application/users.resource'; +import { isEmptyUserList } from '@users/domain/user'; + +@Component({ + selector: 'app-users-page', + imports: [PageShellComponent, AsyncComponent, UserListComponent, UserDetailComponent], + template: ` + + @if (selectedUserId(); as id) { + + } @else { + + @if (usersResource.hasValue()) { + + } + + } + + `, +}) +export class UsersPage { + protected selectedUserId = signal(null); + protected usersResource = usersResource(); + protected listData = computed(() => fromResource(this.usersResource, isEmptyUserList)); +} diff --git a/src/main.ts b/src/main.ts index 5df75f9..190f341 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,5 +2,4 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { appConfig } from './app/app.config'; import { App } from './app/app'; -bootstrapApplication(App, appConfig) - .catch((err) => console.error(err)); +bootstrapApplication(App, appConfig).catch((err) => console.error(err)); diff --git a/tsconfig.json b/tsconfig.json index d2fbb9c..4b42e3f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,13 @@ "experimentalDecorators": true, "importHelpers": true, "target": "ES2022", - "module": "preserve" + "module": "preserve", + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@shared/*": ["src/app/shared/*"], + "@users/*": ["src/app/users/*"] + } }, "angularCompilerOptions": { "enableI18nLegacyMessageIdFormat": false,