Compare commits
3
Commits
78f590af04
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7dddf875a2 | ||
|
|
c895929f58 | ||
|
|
6ad5b65f68 |
@@ -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
|
||||||
@@ -39,6 +39,11 @@ testem.log
|
|||||||
/typings
|
/typings
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
|
# Local secrets / machine-local settings
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
||||||
# System files
|
# System files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
.angular/
|
||||||
|
coverage/
|
||||||
|
package-lock.json
|
||||||
Vendored
-7
@@ -8,13 +8,6 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"preLaunchTask": "npm: start",
|
"preLaunchTask": "npm: start",
|
||||||
"url": "http://localhost:4200/"
|
"url": "http://localhost:4200/"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ng test",
|
|
||||||
"type": "chrome",
|
|
||||||
"request": "launch",
|
|
||||||
"preLaunchTask": "npm: test",
|
|
||||||
"url": "http://localhost:9876/debug.html"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-19
@@ -15,25 +15,7 @@
|
|||||||
"regexp": "Changes detected"
|
"regexp": "Changes detected"
|
||||||
},
|
},
|
||||||
"endsPattern": {
|
"endsPattern": {
|
||||||
"regexp": "bundle generation (complete|failed)"
|
"regexp": "Application 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)"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
A minimal Angular 22 starter: signals for state, `resource()` for async data, and a
|
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
|
`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
|
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 →
|
the whole pattern end to end, including a routed, deep-linkable action (click a user →
|
||||||
go back).
|
see their details at `/users/:id` → go back).
|
||||||
|
|
||||||
It's extracted from a larger reference app — the "POC" referenced throughout this
|
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,
|
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
|
## Running it
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # ng serve
|
npm start # ng serve
|
||||||
npm test # ng test (Vitest)
|
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
|
## Architecture
|
||||||
@@ -31,12 +34,13 @@ Growth path.
|
|||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph shared["shared/ (cross-context)"]
|
subgraph shared["shared/ (cross-context)"]
|
||||||
sapp["application/\nremote-data.ts"]
|
sapp["application/\nremote-data.ts"]
|
||||||
|
sinfra["infrastructure/\nhttp.ts (the only fetch() caller)"]
|
||||||
sui["ui/\natoms → molecules → templates"]
|
sui["ui/\natoms → molecules → templates"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph users["users/ (one context)"]
|
subgraph users["users/ (one context)"]
|
||||||
udom["domain/\nuser.ts (no Angular import)"]
|
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"]
|
uapp["application/\n*.resource.ts"]
|
||||||
uui["ui/\norganisms + page"]
|
uui["ui/\norganisms + page"]
|
||||||
end
|
end
|
||||||
@@ -44,6 +48,7 @@ flowchart TD
|
|||||||
uui --> uapp
|
uui --> uapp
|
||||||
uapp --> udom
|
uapp --> udom
|
||||||
uapp --> uinfra
|
uapp --> uinfra
|
||||||
|
uinfra -. uses .-> sinfra
|
||||||
uui -. reuses .-> sui
|
uui -. reuses .-> sui
|
||||||
uapp -. reuses .-> sapp
|
uapp -. reuses .-> sapp
|
||||||
|
|
||||||
@@ -53,9 +58,10 @@ flowchart TD
|
|||||||
|
|
||||||
### Why so few dependencies
|
### Why so few dependencies
|
||||||
|
|
||||||
The whole app runs on `@angular/core`'s signals + `resource()` and native `fetch` —
|
The whole app runs on `@angular/core`'s signals + `resource()`, native `fetch`, and
|
||||||
nothing else is imported directly, even though a couple of these are installed
|
`@angular/router` for navigation — nothing else is imported directly. `rxjs` is a peer
|
||||||
transitively (by `@angular/forms`/`@angular/router`) or available and simply unused.
|
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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -63,36 +69,44 @@ flowchart LR
|
|||||||
|
|
||||||
app -->|imports| core["@angular/core\nsignal · computed · resource"]
|
app -->|imports| core["@angular/core\nsignal · computed · resource"]
|
||||||
app -->|calls| fetchApi["native fetch()"]
|
app -->|calls| fetchApi["native fetch()"]
|
||||||
|
app -->|navigates via| router["@angular/router"]
|
||||||
|
|
||||||
app -.->|"installed transitively,\nnever imported directly"| rxjs["rxjs"]
|
app -.->|"peer dep of @angular/core,\nnever imported directly"| rxjs["rxjs"]
|
||||||
app -.->|"installed,\nnever used — signal swap instead"| router["@angular/router"]
|
|
||||||
app -.->|"never installed —\nzoneless by default"| zonejs["zone.js"]
|
app -.->|"never installed —\nzoneless by default"| zonejs["zone.js"]
|
||||||
app -.->|"never installed"| ngrx["NgRx / any store lib"]
|
app -.->|"never installed"| ngrx["NgRx / any store lib"]
|
||||||
app -.->|"never installed —\nfetch() instead"| http["HttpClient"]
|
app -.->|"never installed —\nfetch() instead"| http["HttpClient"]
|
||||||
|
|
||||||
classDef used fill:#dfe,stroke:#4a4
|
classDef used fill:#dfe,stroke:#4a4
|
||||||
classDef avoided fill:#fee,stroke:#a44,stroke-dasharray: 4 4
|
classDef avoided fill:#fee,stroke:#a44,stroke-dasharray: 4 4
|
||||||
class core,fetchApi used
|
class core,fetchApi,router used
|
||||||
class rxjs,router,zonejs,ngrx,http avoided
|
class rxjs,zonejs,ngrx,http avoided
|
||||||
```
|
```
|
||||||
|
|
||||||
## What's here
|
## 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
|
(`Loading | Empty | Failure | Success`) plus `fromResource()`, which projects Angular's
|
||||||
own `resource()` into one. No store, no reducer — `resource()` already holds the async
|
own `resource()` into one. `Failure` carries the real `Error` — no generic error
|
||||||
state; `RemoteData` just normalizes it for exhaustive rendering.
|
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
|
- **`<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
|
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
|
- **`<app-page-shell>`** (`shared/ui/templates/page-shell.component.ts`) — a heading plus
|
||||||
one content slot. That's it.
|
one content slot. That's it.
|
||||||
- **`users/`** — one feature context, laid out the same way a bigger one would be:
|
- **`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
|
`domain/` (pure types, no Angular import), `infrastructure/` (parses and type-guards
|
||||||
call `fetch`), `application/` (composes infrastructure + `resource()` — this is also
|
the raw API response into `User`/`UserDetail`, throwing a typed `HttpError` or
|
||||||
exactly where a real store would slot in later), `ui/` (organisms + the page). Clicking
|
`ParseError` on failure — `shared/infrastructure/http.ts` is the only file that calls
|
||||||
a user in the list sets one plain `signal` on the page, which swaps in a
|
`fetch` directly), `application/` (composes infrastructure + `resource()` — this is
|
||||||
`UserDetailComponent` that does its own independent fetch:
|
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
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -102,6 +116,7 @@ flowchart LR
|
|||||||
participant RD as fromResource()
|
participant RD as fromResource()
|
||||||
participant Async as app-async
|
participant Async as app-async
|
||||||
participant List as app-user-list
|
participant List as app-user-list
|
||||||
|
participant Router as Router
|
||||||
|
|
||||||
Page->>Res: usersResource()
|
Page->>Res: usersResource()
|
||||||
Res->>Api: loader()
|
Res->>Api: loader()
|
||||||
@@ -109,27 +124,35 @@ flowchart LR
|
|||||||
Page->>RD: fromResource(usersResource, isEmptyUserList)
|
Page->>RD: fromResource(usersResource, isEmptyUserList)
|
||||||
RD-->>Async: RemoteData tag (Loading/Empty/Failure/Success)
|
RD-->>Async: RemoteData tag (Loading/Empty/Failure/Success)
|
||||||
Async->>List: render on Success
|
Async->>List: render on Success
|
||||||
List->>Page: select.emit(id)
|
List->>Router: select.emit(id) → navigate(['/users', id])
|
||||||
Page->>Page: selectedUserId.set(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
|
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
|
- Path aliases `@shared/*` and `@users/*` (see `tsconfig.json`) instead of relative
|
||||||
`../../` imports, one per context — add one per new context you create.
|
`../../` 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
|
- 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
|
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)
|
## What's deliberately not here (vs. the POC)
|
||||||
|
|
||||||
| Missing | Why |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| `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. |
|
| `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. |
|
||||||
| Storybook + axe a11y gate | Testing/documentation infrastructure that pays off at a much bigger component count. |
|
| 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. |
|
||||||
| CI pipeline | Nothing to gate yet with one context and no deploy target. |
|
| 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
|
## Growth path — when you outgrow this
|
||||||
|
|
||||||
@@ -146,9 +169,10 @@ not before:
|
|||||||
infrastructure`/`ui` direction this template already follows by convention but doesn't
|
infrastructure`/`ui` direction this template already follows by convention but doesn't
|
||||||
check.
|
check.
|
||||||
- **You're consuming a real backend's OpenAPI contract** → add a `contracts/` layer
|
- **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
|
(wire DTOs) + a generated typed client on top of the hand-written `parse*` boundary
|
||||||
`infrastructure/` (see ADR-0001, `.claude/skills/bff-endpoint/SKILL.md` if you're
|
already in `infrastructure/` (see `users/infrastructure/users.adapter.ts`, and
|
||||||
working from the POC directly).
|
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
|
- **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).
|
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
|
- **A real design system** → vendor your CSS, then bridge your own token names onto it the
|
||||||
|
|||||||
+12
-4
@@ -25,9 +25,7 @@
|
|||||||
"input": "public"
|
"input": "public"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": ["src/styles.css"]
|
||||||
"src/styles.css"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -66,7 +64,17 @@
|
|||||||
"defaultConfiguration": "development"
|
"defaultConfiguration": "development"
|
||||||
},
|
},
|
||||||
"test": {
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ flowchart LR
|
|||||||
Three plain ingredients, no framework required:
|
Three plain ingredients, no framework required:
|
||||||
|
|
||||||
- **State** — a plain object describing what's true right now. Example: `{ count: 0 }`.
|
- **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.)
|
`{ type: 'increment' }`. (Some call this an "action" — same thing.)
|
||||||
- **A pure update function** — a function that takes the current state and a message,
|
- **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
|
and returns the **new** state. "Pure" just means: same inputs always give the same
|
||||||
@@ -122,9 +122,7 @@ export type UsersModel = {
|
|||||||
recentlyViewed: number[];
|
recentlyViewed: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UsersMsg =
|
export type UsersMsg = { type: 'select'; id: number } | { type: 'closeDetail' };
|
||||||
| { type: 'select'; id: number }
|
|
||||||
| { type: 'closeDetail' };
|
|
||||||
|
|
||||||
export const initialUsersModel: UsersModel = {
|
export const initialUsersModel: UsersModel = {
|
||||||
selectedUserId: null,
|
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:
|
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.
|
`reduce` is the one place that keeps them in sync.
|
||||||
|
|
||||||
## 5. Using it from a component
|
## 5. Using it from a component
|
||||||
@@ -189,7 +187,7 @@ consistent.
|
|||||||
|
|
||||||
## 6. Async actions — the store doesn't replace `resource()`
|
## 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`
|
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
|
calls into `reduce` — `reduce` must stay pure (no side effects), so a network call has no
|
||||||
business being inside one.
|
business being inside one.
|
||||||
|
|||||||
Generated
+6
-21
@@ -7,11 +7,11 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "ng-signals-template",
|
"name": "ng-signals-template",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/common": "^22.1.0",
|
"@angular/common": "^22.1.0",
|
||||||
"@angular/compiler": "^22.1.0",
|
"@angular/compiler": "^22.1.0",
|
||||||
"@angular/core": "^22.1.0",
|
"@angular/core": "^22.1.0",
|
||||||
"@angular/forms": "^22.1.0",
|
|
||||||
"@angular/platform-browser": "^22.1.0",
|
"@angular/platform-browser": "^22.1.0",
|
||||||
"@angular/router": "^22.1.0",
|
"@angular/router": "^22.1.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
@@ -26,6 +26,9 @@
|
|||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "~6.0.2",
|
"typescript": "~6.0.2",
|
||||||
"vitest": "^4.0.8"
|
"vitest": "^4.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@acemir/cssom": {
|
"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": {
|
"node_modules/@angular/platform-browser": {
|
||||||
"version": "22.1.0",
|
"version": "22.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.0.tgz",
|
||||||
@@ -3418,6 +3401,7 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
@@ -8085,6 +8069,7 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
+8
-2
@@ -1,12 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "ng-signals-template",
|
"name": "ng-signals-template",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"watch": "ng build --watch --configuration development",
|
"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,
|
"private": true,
|
||||||
"packageManager": "npm@11.12.1",
|
"packageManager": "npm@11.12.1",
|
||||||
@@ -14,7 +21,6 @@
|
|||||||
"@angular/common": "^22.1.0",
|
"@angular/common": "^22.1.0",
|
||||||
"@angular/compiler": "^22.1.0",
|
"@angular/compiler": "^22.1.0",
|
||||||
"@angular/core": "^22.1.0",
|
"@angular/core": "^22.1.0",
|
||||||
"@angular/forms": "^22.1.0",
|
|
||||||
"@angular/platform-browser": "^22.1.0",
|
"@angular/platform-browser": "^22.1.0",
|
||||||
"@angular/router": "^22.1.0",
|
"@angular/router": "^22.1.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||||
|
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [provideBrowserGlobalErrorListeners()],
|
providers: [
|
||||||
|
provideBrowserGlobalErrorListeners(),
|
||||||
|
provideRouter(routes, withComponentInputBinding()),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
<app-users-page />
|
<router-outlet />
|
||||||
|
|||||||
@@ -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
@@ -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 { 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', () => {
|
afterEach(() => {
|
||||||
it('renders the users page heading', async () => {
|
vi.unstubAllGlobals();
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ json: () => Promise.resolve([]) }));
|
});
|
||||||
await TestBed.configureTestingModule({ imports: [App] }).compileComponents();
|
|
||||||
const fixture = TestBed.createComponent(App);
|
describe('App routes', () => {
|
||||||
fixture.detectChanges();
|
it('redirects to /users and renders the page heading', async () => {
|
||||||
expect(fixture.nativeElement.querySelector('h1')?.textContent).toBe('Users');
|
vi.stubGlobal(
|
||||||
vi.unstubAllGlobals();
|
'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
@@ -1,10 +1,9 @@
|
|||||||
import { Component } from '@angular/core';
|
import { Component } from '@angular/core';
|
||||||
import { UsersPage } from '@users/ui/users.page';
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [UsersPage],
|
imports: [RouterOutlet],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.css',
|
|
||||||
})
|
})
|
||||||
export class App {}
|
export class App {}
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import type { Resource } from '@angular/core';
|
|||||||
* Four mutually exclusive async states — the data lives ON the state, so e.g.
|
* 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.
|
* "success with no value" or "error with a stale value" is unrepresentable.
|
||||||
*/
|
*/
|
||||||
export type RemoteData<E, T> =
|
export type RemoteData<T> =
|
||||||
| { tag: 'Loading' }
|
| { tag: 'Loading' }
|
||||||
| { tag: 'Empty' }
|
| { tag: 'Empty' }
|
||||||
| { tag: 'Failure'; error: E }
|
| { tag: 'Failure'; error: Error }
|
||||||
| { tag: 'Success'; value: T };
|
| { tag: 'Success'; value: T };
|
||||||
|
|
||||||
/** Project Angular's own resource() into a RemoteData value. */
|
/** Project Angular's own resource() into a RemoteData value. */
|
||||||
export function fromResource<T>(
|
export function fromResource<T>(
|
||||||
r: Resource<T>,
|
r: Resource<T>,
|
||||||
isEmpty: (v: T) => boolean = () => false,
|
isEmpty: (v: T) => boolean = () => false,
|
||||||
): RemoteData<unknown, T> {
|
): RemoteData<T> {
|
||||||
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
|
const error = r.error();
|
||||||
|
if (error) return { tag: 'Failure', error };
|
||||||
if (r.hasValue()) {
|
if (r.hasValue()) {
|
||||||
const v = r.value();
|
const v = r.value();
|
||||||
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
|
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
|
||||||
|
|||||||
@@ -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 {
|
class HostComponent {
|
||||||
data: RemoteData<unknown, string> = { tag: 'Loading' };
|
data: RemoteData<string> = { tag: 'Loading' };
|
||||||
retried = false;
|
retried = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(data: RemoteData<unknown, string>) {
|
function render(data: RemoteData<string>) {
|
||||||
const fixture = TestBed.createComponent(HostComponent);
|
const fixture = TestBed.createComponent(HostComponent);
|
||||||
fixture.componentInstance.data = data;
|
fixture.componentInstance.data = data;
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import type { RemoteData } from '@shared/application/remote-data';
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class AsyncComponent<T> {
|
export class AsyncComponent<T> {
|
||||||
data = input.required<RemoteData<unknown, T>>();
|
data = input.required<RemoteData<T>>();
|
||||||
emptyText = input('No data.');
|
emptyText = input('No data.');
|
||||||
errorText = input('Something went wrong.');
|
errorText = input('Something went wrong.');
|
||||||
retryText = input('Retry');
|
retryText = input('Retry');
|
||||||
|
|||||||
@@ -2,4 +2,7 @@ import { resource } from '@angular/core';
|
|||||||
import { fetchUserById } from '@users/infrastructure/users.adapter';
|
import { fetchUserById } from '@users/infrastructure/users.adapter';
|
||||||
|
|
||||||
export const userDetailResource = (userId: () => number) =>
|
export const userDetailResource = (userId: () => number) =>
|
||||||
resource({ params: userId, loader: ({ params }) => fetchUserById(params) });
|
resource({
|
||||||
|
params: userId,
|
||||||
|
loader: ({ params, abortSignal }) => fetchUserById(params, abortSignal),
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { resource } from '@angular/core';
|
import { resource } from '@angular/core';
|
||||||
import { fetchUsers } from '@users/infrastructure/users.adapter';
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,32 @@
|
|||||||
|
import { getJson, ParseError } from '@shared/infrastructure/http';
|
||||||
import type { User, UserDetail } from '@users/domain/user';
|
import type { User, UserDetail } from '@users/domain/user';
|
||||||
|
|
||||||
export const fetchUsers = (): Promise<User[]> =>
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
fetch('https://jsonplaceholder.typicode.com/users').then((r) => r.json());
|
typeof value === 'object' && value !== null;
|
||||||
|
|
||||||
export const fetchUserById = (id: number): Promise<UserDetail> =>
|
export function parseUser(value: unknown): User {
|
||||||
fetch(`https://jsonplaceholder.typicode.com/users/${id}`).then((r) => r.json());
|
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(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }),
|
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -27,6 +29,8 @@ describe('UserDetailComponent', () => {
|
|||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn().mockResolvedValue({
|
vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'a@x.com' }),
|
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',
|
selector: 'app-user-detail',
|
||||||
imports: [AsyncComponent],
|
imports: [AsyncComponent],
|
||||||
template: `
|
template: `
|
||||||
<app-async [data]="data()" (retry)="detailResource.reload()">
|
@let detail = data();
|
||||||
@if (detailResource.value(); as u) {
|
<app-async [data]="detail" (retry)="detailResource.reload()">
|
||||||
<p>{{ u.name }} — {{ u.email }}</p>
|
@if (detail.tag === 'Success' && detail.value) {
|
||||||
|
<p>{{ detail.value.name }} — {{ detail.value.email }}</p>
|
||||||
}
|
}
|
||||||
</app-async>
|
</app-async>
|
||||||
<button type="button" (click)="close.emit()">Back</button>
|
<button type="button" (click)="close.emit()">Back</button>
|
||||||
|
|||||||
@@ -1,21 +1,55 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { TestBed } from '@angular/core/testing';
|
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() {
|
function stubFetch() {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
vi.fn((url: string) => {
|
vi.fn((url: string) => {
|
||||||
if (url.endsWith('/users')) {
|
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({
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
json: () => Promise.resolve({ id: 1, name: 'Ada', email: 'ada@example.com' }),
|
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(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
@@ -23,44 +57,50 @@ afterEach(() => {
|
|||||||
describe('UsersPage', () => {
|
describe('UsersPage', () => {
|
||||||
it('renders the fetched users as a list', async () => {
|
it('renders the fetched users as a list', async () => {
|
||||||
stubFetch();
|
stubFetch();
|
||||||
await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents();
|
const harness = await renderUsersAt('/users');
|
||||||
const fixture = TestBed.createComponent(UsersPage);
|
expect(harness.routeNativeElement?.textContent).toContain('Ada');
|
||||||
fixture.detectChanges();
|
});
|
||||||
await fixture.whenStable();
|
|
||||||
fixture.detectChanges();
|
it('shows the failure state when the request fails', async () => {
|
||||||
expect(fixture.nativeElement.textContent).toContain('Ada');
|
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 () => {
|
it("shows a user's details after clicking their name", async () => {
|
||||||
stubFetch();
|
stubFetch();
|
||||||
await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents();
|
const harness = await renderUsersAt('/users');
|
||||||
const fixture = TestBed.createComponent(UsersPage);
|
harness.routeNativeElement!.querySelector('button')!.click();
|
||||||
fixture.detectChanges();
|
await harness.fixture.whenStable();
|
||||||
await fixture.whenStable();
|
harness.detectChanges();
|
||||||
fixture.detectChanges();
|
expect(harness.routeNativeElement?.textContent).toContain('ada@example.com');
|
||||||
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 () => {
|
it('returns to the list when the detail view is closed', async () => {
|
||||||
stubFetch();
|
stubFetch();
|
||||||
await TestBed.configureTestingModule({ imports: [UsersPage] }).compileComponents();
|
const harness = await renderUsersAt('/users');
|
||||||
const fixture = TestBed.createComponent(UsersPage);
|
harness.routeNativeElement!.querySelector('button')!.click();
|
||||||
fixture.detectChanges();
|
await harness.fixture.whenStable();
|
||||||
await fixture.whenStable();
|
harness.detectChanges();
|
||||||
fixture.detectChanges();
|
const backButton = Array.from(harness.routeNativeElement!.querySelectorAll('button')).find(
|
||||||
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',
|
(b) => (b as HTMLElement).textContent === 'Back',
|
||||||
) as HTMLElement;
|
) as HTMLElement;
|
||||||
backButton.click();
|
backButton.click();
|
||||||
fixture.detectChanges();
|
await harness.fixture.whenStable();
|
||||||
expect(fixture.nativeElement.querySelector('app-user-detail')).toBeFalsy();
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 { PageShellComponent } from '@shared/ui/templates/page-shell.component';
|
||||||
import { AsyncComponent } from '@shared/ui/molecules/async.component';
|
import { AsyncComponent } from '@shared/ui/molecules/async.component';
|
||||||
import { fromResource } from '@shared/application/remote-data';
|
import { fromResource } from '@shared/application/remote-data';
|
||||||
@@ -13,11 +14,12 @@ import { isEmptyUserList } from '@users/domain/user';
|
|||||||
template: `
|
template: `
|
||||||
<app-page-shell heading="Users">
|
<app-page-shell heading="Users">
|
||||||
@if (selectedUserId(); as id) {
|
@if (selectedUserId(); as id) {
|
||||||
<app-user-detail [userId]="id" (close)="selectedUserId.set(null)" />
|
<app-user-detail [userId]="id" (close)="router.navigate(['/users'])" />
|
||||||
} @else {
|
} @else {
|
||||||
<app-async [data]="listData()" (retry)="usersResource.reload()">
|
@let list = listData();
|
||||||
@if (usersResource.hasValue()) {
|
<app-async [data]="list" (retry)="usersResource.reload()">
|
||||||
<app-user-list [users]="usersResource.value()!" (select)="selectedUserId.set($event)" />
|
@if (list.tag === 'Success' && list.value) {
|
||||||
|
<app-user-list [users]="list.value" (select)="router.navigate(['/users', $event])" />
|
||||||
}
|
}
|
||||||
</app-async>
|
</app-async>
|
||||||
}
|
}
|
||||||
@@ -25,7 +27,12 @@ import { isEmptyUserList } from '@users/domain/user';
|
|||||||
`,
|
`,
|
||||||
})
|
})
|
||||||
export class UsersPage {
|
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 usersResource = usersResource();
|
||||||
protected listData = computed(() => fromResource(this.usersResource, isEmptyUserList));
|
protected listData = computed(() => fromResource(this.usersResource, isEmptyUserList));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1,13 +1,13 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<title>NgSignalsTemplate</title>
|
<title>NgSignalsTemplate</title>
|
||||||
<base href="/">
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+2
-6
@@ -5,10 +5,6 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src/**/*.ts"],
|
||||||
"src/**/*.ts"
|
"exclude": ["src/**/*.spec.ts"]
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"src/**/*.spec.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
"noPropertyAccessFromIndexSignature": true,
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"angularCompilerOptions": {
|
"angularCompilerOptions": {
|
||||||
|
"strictTemplates": true,
|
||||||
"enableI18nLegacyMessageIdFormat": false,
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
"strictInjectionParameters": true,
|
"strictInjectionParameters": true,
|
||||||
"strictInputAccessModifiers": true
|
"strictInputAccessModifiers": true
|
||||||
|
|||||||
+2
-7
@@ -3,12 +3,7 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": [
|
"types": ["vitest/globals"]
|
||||||
"vitest/globals"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
|
||||||
"src/**/*.d.ts",
|
|
||||||
"src/**/*.spec.ts"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user