Compare commits

...
3 Commits
Author SHA1 Message Date
ehoandClaude Sonnet 5 7dddf875a2 docs: bring README in line with strict mode, routing, and the HTTP boundary
ci / verify (push) Successful in 47s
Updates the architecture/dependency diagrams and omissions table to match
what actually shipped: RemoteData<T> (not <E, T>), @angular/router moved
from "avoided" to "used", the new shared/infrastructure/http.ts fetch
boundary, and the CI/coverage gates now in place. Removes the now-false
"CI pipeline" omission row and adds honest rows for the linter and
per-environment config this template still deliberately skips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 10:30:02 +02:00
ehoandClaude Sonnet 5 c895929f58 fix: enable strict mode, honest HTTP boundary, and add routing
Three fixes to the parts of the template that contradicted its own
"illegal states unrepresentable" claim:

- tsconfig: turn on strict + strictTemplates (measured zero fallout —
  the codebase already typechecked cleanly, it just wasn't enforced).
- RemoteData<T> drops its unused error type parameter (Resource.error
  is always Error) and Failure now carries a real Error. Pages read the
  union with @let instead of re-deriving from the resource, which
  deletes the non-null assertion strictNullChecks would otherwise flag.
- users.adapter.ts never checked response.ok, so an HTTP error resolved
  as a garbage Success and crashed instead of reaching RemoteData's
  Failure branch. New shared/infrastructure/http.ts adds the status
  check plus hand-written parse guards and abortSignal forwarding; the
  six fetch stubs across the test suite (which encoded the missing
  check) and adapter spec now cover the Failure and Empty paths.

Also adds real routing (@angular/router was a dependency with zero
imports and a fake "Back" button): /users and /users/:id are now
deep-linkable via withComponentInputBinding(), tested with
RouterTestingHarness driving real navigation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 10:29:47 +02:00
ehoandClaude Sonnet 5 6ad5b65f68 chore: add CI, coverage gates, and fix template hygiene
Wires the previously-inert @vitest/coverage-v8 into the test builder with
measured thresholds, adds a GitHub Actions workflow (format, test, build),
and fixes assorted gaps a cloning team would hit: no LICENSE, no engines/
.nvmrc, an unusable prettier install, a committed local-only settings file,
a dead 0-byte stylesheet, and a broken Karma-era VS Code test/debug config.
Also drops @angular/forms, which nothing in src/ imports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 10:29:32 +02:00
35 changed files with 462 additions and 185 deletions
+39
View File
@@ -0,0 +1,39 @@
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Format
run: npm run format:check
# `ng test` type-checks tsconfig.spec.json and `ng build` type-checks
# tsconfig.app.json *including templates* (strictTemplates), so every .ts
# file is already type-checked by the steps below. A separate
# `tsc --noEmit` step would be redundant.
- name: Test
run: npm run test:coverage
- name: Build
run: npm run build
+5
View File
@@ -39,6 +39,11 @@ testem.log
/typings
__screenshots__/
# Local secrets / machine-local settings
.env
.env.*
.claude/settings.local.json
# System files
.DS_Store
Thumbs.db
+1
View File
@@ -0,0 +1 @@
24
+4
View File
@@ -0,0 +1,4 @@
dist/
.angular/
coverage/
package-lock.json
-7
View File
@@ -8,13 +8,6 @@
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}
+1 -19
View File
@@ -15,25 +15,7 @@
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "Changes detected"
},
"endsPattern": {
"regexp": "bundle generation (complete|failed)"
"regexp": "Application bundle generation (complete|failed)"
}
}
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ng-signals-template contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+60 -36
View File
@@ -3,8 +3,8 @@
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).
the whole pattern end to end, including a routed, deep-linkable action (click a user →
see their details at `/users/:id`go back).
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,
@@ -15,8 +15,11 @@ exactly where to reach for the rest as a project grows.
## Running it
```bash
npm start # ng serve
npm test # ng test (Vitest)
npm start # ng serve
npm test # ng test (Vitest)
npm run test:coverage # ng test --coverage, gated by thresholds in angular.json
npm run format:check # prettier --check .
npm run build # production build
```
## Architecture
@@ -31,12 +34,13 @@ Growth path.
flowchart TD
subgraph shared["shared/ (cross-context)"]
sapp["application/\nremote-data.ts"]
sinfra["infrastructure/\nhttp.ts (the only fetch() caller)"]
sui["ui/\natoms → molecules → templates"]
end
subgraph users["users/ (one context)"]
udom["domain/\nuser.ts (no Angular import)"]
uinfra["infrastructure/\nusers.adapter.ts (the only fetch() caller)"]
uinfra["infrastructure/\nusers.adapter.ts (parses + type-guards)"]
uapp["application/\n*.resource.ts"]
uui["ui/\norganisms + page"]
end
@@ -44,6 +48,7 @@ flowchart TD
uui --> uapp
uapp --> udom
uapp --> uinfra
uinfra -. uses .-> sinfra
uui -. reuses .-> sui
uapp -. reuses .-> sapp
@@ -53,9 +58,10 @@ flowchart TD
### Why so few dependencies
The whole app runs on `@angular/core`'s signals + `resource()` and native `fetch`
nothing else is imported directly, even though a couple of these are installed
transitively (by `@angular/forms`/`@angular/router`) or available and simply unused.
The whole app runs on `@angular/core`'s signals + `resource()`, native `fetch`, and
`@angular/router` for navigation — nothing else is imported directly. `rxjs` is a peer
dependency of `@angular/core` itself and is never imported by this template's own code.
`@angular/forms` has been dropped entirely — nothing here used it.
```mermaid
flowchart LR
@@ -63,36 +69,44 @@ flowchart LR
app -->|imports| core["@angular/core\nsignal · computed · resource"]
app -->|calls| fetchApi["native fetch()"]
app -->|navigates via| router["@angular/router"]
app -.->|"installed transitively,\nnever imported directly"| rxjs["rxjs"]
app -.->|"installed,\nnever used — signal swap instead"| router["@angular/router"]
app -.->|"peer dep of @angular/core,\nnever imported directly"| rxjs["rxjs"]
app -.->|"never installed —\nzoneless by default"| zonejs["zone.js"]
app -.->|"never installed"| ngrx["NgRx / any store lib"]
app -.->|"never installed —\nfetch() instead"| http["HttpClient"]
classDef used fill:#dfe,stroke:#4a4
classDef avoided fill:#fee,stroke:#a44,stroke-dasharray: 4 4
class core,fetchApi used
class rxjs,router,zonejs,ngrx,http avoided
class core,fetchApi,router used
class rxjs,zonejs,ngrx,http avoided
```
## What's here
- **`RemoteData<E, T>`** (`shared/application/remote-data.ts`) — a 4-variant union
- **`RemoteData<T>`** (`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.
own `resource()` into one. `Failure` carries the real `Error` — no generic error
parameter to instantiate, since Angular's `resource()` only ever fails with an `Error`.
No store, no reducer — `resource()` already holds the async state; `RemoteData` just
normalizes it for exhaustive rendering.
- **`<app-async>`** (`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/`.
button, or your projected content on success. Because `<ng-content>` can't hand data
back to its parent, pages narrow the same `RemoteData` value themselves with `@let`
before rendering their payload — see `users.page.ts` for the pattern. Reused by both
fetches in `users/`.
- **`<app-page-shell>`** (`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:
`domain/` (pure types, no Angular import), `infrastructure/` (parses and type-guards
the raw API response into `User`/`UserDetail`, throwing a typed `HttpError` or
`ParseError` on failure — `shared/infrastructure/http.ts` is the only file that calls
`fetch` directly), `application/` (composes infrastructure + `resource()` this is
also exactly where a real store would slot in later), `ui/` (organisms + the page).
Routing is wired with `withComponentInputBinding()`, so clicking a user navigates to
`/users/:id` and the route param binds straight onto the page's `userId` input — no
manual signal wiring, and the detail view is deep-linkable:
```mermaid
sequenceDiagram
@@ -102,6 +116,7 @@ flowchart LR
participant RD as fromResource()
participant Async as app-async
participant List as app-user-list
participant Router as Router
Page->>Res: usersResource()
Res->>Api: loader()
@@ -109,27 +124,35 @@ flowchart LR
Page->>RD: fromResource(usersResource, isEmptyUserList)
RD-->>Async: RemoteData tag (Loading/Empty/Failure/Success)
Async->>List: render on Success
List->>Page: select.emit(id)
Page->>Page: selectedUserId.set(id)
List->>Router: select.emit(id) → navigate(['/users', id])
Router->>Page: binds userId input from the route
Note over Page: template swaps to app-user-detail,<br/>which repeats the same chain via userDetailResource
```
- 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.
never breaks just because an internal was refactored. Routed pages are tested with
`RouterTestingHarness` driving real navigation (see `users.page.spec.ts`), not by
poking at signals directly.
- **Type safety & CI** — `strict` and `strictTemplates` are both on, so `RemoteData`'s
"illegal states unrepresentable" claim is actually checked by the compiler, not just a
convention. `.github/workflows/ci.yml` runs a format check, the test suite (with
coverage thresholds), and a production build on every push and pull request.
## What's deliberately not here (vs. the POC)
| 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. |
| 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 | A hand-written `parse*` boundary already guards the response shape (see `users/infrastructure/users.adapter.ts`); only add codegen once there's a real OpenAPI contract to generate from, not a public test API. |
| Linter (`ESLint`/`angular-eslint`) | `strict` + `strictTemplates` + Prettier already catch most of what a linter would here; add one when you have team-specific rules — it's five more devDependencies and a config most teams rewrite anyway. |
| Per-environment config (`.env` / `fileReplacements`) | The API base URL is a single exported `const` in `shared/infrastructure/http.ts` — greppable, zero ceremony. Swap for Angular's built-in `fileReplacements` + `src/environments/` when you need per-deploy values. |
| Storybook + axe a11y gate | Testing/documentation infrastructure that pays off at a much bigger component count. |
## Growth path — when you outgrow this
@@ -146,9 +169,10 @@ not before:
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).
(wire DTOs) + a generated typed client on top of the hand-written `parse*` boundary
already in `infrastructure/` (see `users/infrastructure/users.adapter.ts`, and
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
+12 -4
View File
@@ -25,9 +25,7 @@
"input": "public"
}
],
"styles": [
"src/styles.css"
]
"styles": ["src/styles.css"]
},
"configurations": {
"production": {
@@ -66,7 +64,17 @@
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular/build:unit-test"
"builder": "@angular/build:unit-test",
"options": {
"coverageExclude": ["src/main.ts", "**/*.spec.ts"],
"coverageReporters": ["text-summary", "lcovonly"],
"coverageThresholds": {
"statements": 90,
"branches": 90,
"functions": 85,
"lines": 95
}
}
}
}
}
+4 -6
View File
@@ -34,7 +34,7 @@ flowchart LR
Three plain ingredients, no framework required:
- **State** — a plain object describing what's true right now. Example: `{ count: 0 }`.
- **A message** — a plain object describing *what happened*. Example:
- **A message** — a plain object describing _what happened_. Example:
`{ type: 'increment' }`. (Some call this an "action" — same thing.)
- **A pure update function** — a function that takes the current state and a message,
and returns the **new** state. "Pure" just means: same inputs always give the same
@@ -122,9 +122,7 @@ export type UsersModel = {
recentlyViewed: number[];
};
export type UsersMsg =
| { type: 'select'; id: number }
| { type: 'closeDetail' };
export type UsersMsg = { type: 'select'; id: number } | { type: 'closeDetail' };
export const initialUsersModel: UsersModel = {
selectedUserId: null,
@@ -145,7 +143,7 @@ export function reduceUsers(model: UsersModel, msg: UsersMsg): UsersModel {
```
This is the point where a single `signal<number | null>` stopped being enough:
`recentlyViewed` is a second field that changes *together* with `selectedUserId`, and
`recentlyViewed` is a second field that changes _together_ with `selectedUserId`, and
`reduce` is the one place that keeps them in sync.
## 5. Using it from a component
@@ -189,7 +187,7 @@ consistent.
## 6. Async actions — the store doesn't replace `resource()`
Keep these concerns separate: `resource()` still owns *fetching*; the store only owns
Keep these concerns separate: `resource()` still owns _fetching_; the store only owns
synchronous state that's derived from, or reacts to, what's fetched. Don't move `fetch`
calls into `reduce``reduce` must stay pure (no side effects), so a network call has no
business being inside one.
+6 -21
View File
@@ -7,11 +7,11 @@
"": {
"name": "ng-signals-template",
"version": "0.0.0",
"license": "MIT",
"dependencies": {
"@angular/common": "^22.1.0",
"@angular/compiler": "^22.1.0",
"@angular/core": "^22.1.0",
"@angular/forms": "^22.1.0",
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"rxjs": "~7.8.0",
@@ -26,6 +26,9 @@
"prettier": "^3.8.1",
"typescript": "~6.0.2",
"vitest": "^4.0.8"
},
"engines": {
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
}
},
"node_modules/@acemir/cssom": {
@@ -373,26 +376,6 @@
}
}
},
"node_modules/@angular/forms": {
"version": "22.1.0",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.0.tgz",
"integrity": "sha512-nWlSM/pPp78Sx/fBM/tFEgZxdfZe50LkCE2/hkO22Fi1UM2maGc43LDsu/s6l0q9hFep4Wj+xa30KXDBS7Cn8A==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"tslib": "^2.3.0",
"zod": "^4.0.10"
},
"engines": {
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
},
"peerDependencies": {
"@angular/common": "22.1.0",
"@angular/core": "22.1.0",
"@angular/platform-browser": "22.1.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
"version": "22.1.0",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.0.tgz",
@@ -3418,6 +3401,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
@@ -8085,6 +8069,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
+8 -2
View File
@@ -1,12 +1,19 @@
{
"name": "ng-signals-template",
"version": "0.0.0",
"license": "MIT",
"engines": {
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
},
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
"test": "ng test",
"test:coverage": "ng test --coverage",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"private": true,
"packageManager": "npm@11.12.1",
@@ -14,7 +21,6 @@
"@angular/common": "^22.1.0",
"@angular/compiler": "^22.1.0",
"@angular/core": "^22.1.0",
"@angular/forms": "^22.1.0",
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"rxjs": "~7.8.0",
+6 -1
View File
@@ -1,5 +1,10 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [provideBrowserGlobalErrorListeners()],
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
],
};
View File
+1 -1
View File
@@ -1 +1 @@
<app-users-page />
<router-outlet />
+9
View File
@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'users',
loadChildren: () => import('@users/ui/users.routes').then((m) => m.usersRoutes),
},
{ path: '', redirectTo: 'users', pathMatch: 'full' },
];
+21 -10
View File
@@ -1,14 +1,25 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { App } from './app';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { routes } from './app.routes';
describe('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);
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('h1')?.textContent).toBe('Users');
vi.unstubAllGlobals();
afterEach(() => {
vi.unstubAllGlobals();
});
describe('App routes', () => {
it('redirects to /users and renders the page heading', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve([]) }),
);
TestBed.configureTestingModule({
providers: [provideRouter(routes, withComponentInputBinding())],
});
const harness = await RouterTestingHarness.create('/');
await harness.fixture.whenStable();
harness.detectChanges();
expect(harness.routeNativeElement?.querySelector('h1')?.textContent).toBe('Users');
});
});
+2 -3
View File
@@ -1,10 +1,9 @@
import { Component } from '@angular/core';
import { UsersPage } from '@users/ui/users.page';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [UsersPage],
imports: [RouterOutlet],
templateUrl: './app.html',
styleUrl: './app.css',
})
export class App {}
+5 -4
View File
@@ -4,18 +4,19 @@ 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<E, T> =
export type RemoteData<T> =
| { tag: 'Loading' }
| { tag: 'Empty' }
| { tag: 'Failure'; error: E }
| { tag: 'Failure'; error: Error }
| { tag: 'Success'; value: T };
/** Project Angular's own resource() into a RemoteData value. */
export function fromResource<T>(
r: Resource<T>,
isEmpty: (v: T) => boolean = () => false,
): RemoteData<unknown, T> {
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
): RemoteData<T> {
const error = r.error();
if (error) return { tag: 'Failure', error };
if (r.hasValue()) {
const v = r.value();
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
+34
View File
@@ -0,0 +1,34 @@
export const API_BASE_URL = 'https://jsonplaceholder.typicode.com';
/** Server answered, but not with a 2xx. */
export class HttpError extends Error {
constructor(
readonly status: number,
path: string,
) {
super(`HTTP ${status} for ${path}`);
this.name = 'HttpError';
}
}
/** Server answered 2xx, but the body isn't the shape we asked for. */
export class ParseError extends Error {
constructor(what: string) {
super(`Malformed response: expected ${what}`);
this.name = 'ParseError';
}
}
/**
* GET + status check + parse, in that order. Throws HttpError or ParseError —
* resource() turns a thrown error into RemoteData's Failure branch.
*/
export async function getJson<T>(
path: string,
parse: (value: unknown) => T,
abortSignal?: AbortSignal,
): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, { signal: abortSignal });
if (!response.ok) throw new HttpError(response.status, path);
return parse(await response.json());
}
@@ -13,11 +13,11 @@ import type { RemoteData } from '@shared/application/remote-data';
`,
})
class HostComponent {
data: RemoteData<unknown, string> = { tag: 'Loading' };
data: RemoteData<string> = { tag: 'Loading' };
retried = false;
}
function render(data: RemoteData<unknown, string>) {
function render(data: RemoteData<string>) {
const fixture = TestBed.createComponent(HostComponent);
fixture.componentInstance.data = data;
fixture.detectChanges();
@@ -24,7 +24,7 @@ import type { RemoteData } from '@shared/application/remote-data';
`,
})
export class AsyncComponent<T> {
data = input.required<RemoteData<unknown, T>>();
data = input.required<RemoteData<T>>();
emptyText = input('No data.');
errorText = input('Something went wrong.');
retryText = input('Retry');
@@ -2,4 +2,7 @@ import { resource } from '@angular/core';
import { fetchUserById } from '@users/infrastructure/users.adapter';
export const userDetailResource = (userId: () => number) =>
resource({ params: userId, loader: ({ params }) => fetchUserById(params) });
resource({
params: userId,
loader: ({ params, abortSignal }) => fetchUserById(params, abortSignal),
});
+2 -1
View File
@@ -1,4 +1,5 @@
import { resource } from '@angular/core';
import { fetchUsers } from '@users/infrastructure/users.adapter';
export const usersResource = () => resource({ loader: fetchUsers });
export const usersResource = () =>
resource({ loader: ({ abortSignal }) => fetchUsers(abortSignal) });
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpError, ParseError } from '@shared/infrastructure/http';
import { fetchUserById, fetchUsers, parseUser, parseUserDetail, parseUsers } from './users.adapter';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('fetchUsers', () => {
it('resolves with the parsed list on a 200 response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve([{ id: 1, name: 'Ada' }]),
}),
);
await expect(fetchUsers()).resolves.toEqual([{ id: 1, name: 'Ada' }]);
});
it('rejects with HttpError when the server answers with a non-2xx status', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
await expect(fetchUsers()).rejects.toBeInstanceOf(HttpError);
});
});
describe('fetchUserById', () => {
it('resolves with the parsed user on a 200 response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }),
}),
);
await expect(fetchUserById(1)).resolves.toEqual({
id: 1,
name: 'Ada',
email: 'ada@example.com',
});
});
it('rejects with HttpError when the server answers with a non-2xx status', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
await expect(fetchUserById(1)).rejects.toBeInstanceOf(HttpError);
});
});
describe('parseUsers', () => {
it('throws ParseError when the response is not an array', () => {
expect(() => parseUsers({})).toThrow(ParseError);
});
it('throws ParseError when a list item is missing a name', () => {
expect(() => parseUsers([{ id: 1 }])).toThrow(ParseError);
});
});
describe('parseUser', () => {
it('throws ParseError when id is not a number', () => {
expect(() => parseUser({ id: '1', name: 'Ada' })).toThrow(ParseError);
});
});
describe('parseUserDetail', () => {
it('throws ParseError when email is missing', () => {
expect(() => parseUserDetail({ id: 1, name: 'Ada' })).toThrow(ParseError);
});
});
+29 -4
View File
@@ -1,7 +1,32 @@
import { getJson, ParseError } from '@shared/infrastructure/http';
import type { User, UserDetail } from '@users/domain/user';
export const fetchUsers = (): Promise<User[]> =>
fetch('https://jsonplaceholder.typicode.com/users').then((r) => r.json());
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
export const fetchUserById = (id: number): Promise<UserDetail> =>
fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((r) => r.json());
export function parseUser(value: unknown): User {
if (!isRecord(value)) throw new ParseError('a user');
const { id, name } = value;
if (typeof id !== 'number' || typeof name !== 'string') throw new ParseError('a user');
return { id, name };
}
export function parseUserDetail(value: unknown): UserDetail {
if (!isRecord(value)) throw new ParseError('a user detail');
const { id, name, email } = value;
if (typeof id !== 'number' || typeof name !== 'string' || typeof email !== 'string') {
throw new ParseError('a user detail');
}
return { id, name, email };
}
export function parseUsers(value: unknown): User[] {
if (!Array.isArray(value)) throw new ParseError('a user list');
return value.map(parseUser);
}
export const fetchUsers = (abortSignal?: AbortSignal): Promise<User[]> =>
getJson('/users', parseUsers, abortSignal);
export const fetchUserById = (id: number, abortSignal?: AbortSignal): Promise<UserDetail> =>
getJson(`/users/${id}`, parseUserDetail, abortSignal);
@@ -11,6 +11,8 @@ describe('UserDetailComponent', () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }),
}),
);
@@ -27,6 +29,8 @@ describe('UserDetailComponent', () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'a@x.com' }),
}),
);
@@ -7,9 +7,10 @@ import { userDetailResource } from '@users/application/user-detail.resource';
selector: 'app-user-detail',
imports: [AsyncComponent],
template: `
<app-async [data]="data()" (retry)="detailResource.reload()">
@if (detailResource.value(); as u) {
<p>{{ u.name }} — {{ u.email }}</p>
@let detail = data();
<app-async [data]="detail" (retry)="detailResource.reload()">
@if (detail.tag === 'Success' && detail.value) {
<p>{{ detail.value.name }} — {{ detail.value.email }}</p>
}
</app-async>
<button type="button" (click)="close.emit()">Back</button>
+70 -30
View File
@@ -1,21 +1,55 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { UsersPage } from './users.page';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { usersRoutes } from './users.routes';
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({
ok: true,
status: 200,
json: () => Promise.resolve([{ id: 1, name: 'Ada' }]),
});
}
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }),
});
}),
);
}
function stubFetchFailure() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: false, status: 500, json: () => Promise.resolve({}) }),
);
}
function stubFetchEmpty() {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ ok: true, status: 200, json: () => Promise.resolve([]) }),
);
}
async function renderUsersAt(url: string) {
TestBed.configureTestingModule({
providers: [
provideRouter([{ path: 'users', children: usersRoutes }], withComponentInputBinding()),
],
});
const harness = await RouterTestingHarness.create(url);
await harness.fixture.whenStable();
harness.detectChanges();
return harness;
}
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -23,44 +57,50 @@ afterEach(() => {
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');
const harness = await renderUsersAt('/users');
expect(harness.routeNativeElement?.textContent).toContain('Ada');
});
it('shows the failure state when the request fails', async () => {
stubFetchFailure();
const harness = await renderUsersAt('/users');
expect(harness.routeNativeElement?.querySelector('[role="alert"]')).toBeTruthy();
});
it('shows the empty state when there are no users', async () => {
stubFetchEmpty();
const harness = await renderUsersAt('/users');
expect(harness.routeNativeElement?.textContent).toContain('No data.');
});
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');
const harness = await renderUsersAt('/users');
harness.routeNativeElement!.querySelector('button')!.click();
await harness.fixture.whenStable();
harness.detectChanges();
expect(harness.routeNativeElement?.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(
const harness = await renderUsersAt('/users');
harness.routeNativeElement!.querySelector('button')!.click();
await harness.fixture.whenStable();
harness.detectChanges();
const backButton = Array.from(harness.routeNativeElement!.querySelectorAll('button')).find(
(b) => (b as HTMLElement).textContent === 'Back',
) as HTMLElement;
backButton.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-user-detail')).toBeFalsy();
await harness.fixture.whenStable();
harness.detectChanges();
expect(harness.routeNativeElement?.querySelector('app-user-detail')).toBeFalsy();
expect(harness.routeNativeElement?.textContent).toContain('Ada');
});
it('deep-links directly to a user detail view', async () => {
stubFetch();
const harness = await renderUsersAt('/users/1');
expect(harness.routeNativeElement?.textContent).toContain('ada@example.com');
});
});
+13 -6
View File
@@ -1,4 +1,5 @@
import { Component, computed, signal } from '@angular/core';
import { Component, computed, inject, input } from '@angular/core';
import { Router } from '@angular/router';
import { PageShellComponent } from '@shared/ui/templates/page-shell.component';
import { AsyncComponent } from '@shared/ui/molecules/async.component';
import { fromResource } from '@shared/application/remote-data';
@@ -13,11 +14,12 @@ import { isEmptyUserList } from '@users/domain/user';
template: `
<app-page-shell heading="Users">
@if (selectedUserId(); as id) {
<app-user-detail [userId]="id" (close)="selectedUserId.set(null)" />
<app-user-detail [userId]="id" (close)="router.navigate(['/users'])" />
} @else {
<app-async [data]="listData()" (retry)="usersResource.reload()">
@if (usersResource.hasValue()) {
<app-user-list [users]="usersResource.value()!" (select)="selectedUserId.set($event)" />
@let list = listData();
<app-async [data]="list" (retry)="usersResource.reload()">
@if (list.tag === 'Success' && list.value) {
<app-user-list [users]="list.value" (select)="router.navigate(['/users', $event])" />
}
</app-async>
}
@@ -25,7 +27,12 @@ import { isEmptyUserList } from '@users/domain/user';
`,
})
export class UsersPage {
protected selectedUserId = signal<number | null>(null);
protected router = inject(Router);
userId = input<string>();
protected selectedUserId = computed(() => {
const id = this.userId();
return id ? Number(id) : null;
});
protected usersResource = usersResource();
protected listData = computed(() => fromResource(this.usersResource, isEmptyUserList));
}
+7
View File
@@ -0,0 +1,7 @@
import { Routes } from '@angular/router';
import { UsersPage } from './users.page';
export const usersRoutes: Routes = [
{ path: '', component: UsersPage },
{ path: ':userId', component: UsersPage },
];
+10 -10
View File
@@ -1,13 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>NgSignalsTemplate</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body>
<app-root></app-root>
</body>
<head>
<meta charset="utf-8" />
<title>NgSignalsTemplate</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>
+2 -6
View File
@@ -5,10 +5,6 @@
"compilerOptions": {
"types": []
},
"include": [
"src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.spec.ts"]
}
+2
View File
@@ -3,6 +3,7 @@
{
"compileOnSave": false,
"compilerOptions": {
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
@@ -21,6 +22,7 @@
}
},
"angularCompilerOptions": {
"strictTemplates": true,
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true
+2 -7
View File
@@ -3,12 +3,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": [
"vitest/globals"
]
"types": ["vitest/globals"]
},
"include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
}