diff --git a/README.md b/README.md index 4053892..fe1c9df 100644 --- a/README.md +++ b/README.md @@ -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`** (`shared/application/remote-data.ts`) — a 4-variant union +- **`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. + 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. - **``** (`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 `` 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/`. - **``** (`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,
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 diff --git a/docs/adding-a-store.md b/docs/adding-a-store.md index 4e04fa2..2f42b2d 100644 --- a/docs/adding-a-store.md +++ b/docs/adding-a-store.md @@ -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` 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.