New `brief` context — a letter-composition feature with a drafter/approver approval workflow, built as a teaching vertical slice on the repo's existing FP + Elm + atomic-design patterns (see plan in ~/.claude/plans). Domain (pure): - Rich text as a serialisable value tree (placeholders are first-class nodes), moved to @shared/kernel/rich-text.ts so the shared editor can use it. - lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored. - brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot = deep value copy; derived diagnostics/editability. Full specs. Backend (.NET stub): - BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints, role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards, audit logging. Regenerated typed client via gen:api. +6 backend tests. Seam: - brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the parse boundary (+ spec). UI (atomic): - shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep contenteditable, DOM<->RichTextBlock round-trip tested). - brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments, letter-section, letter-composer, letter-preview, brief.page + /brief route. - Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link. Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared). Also included (same session): - Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap. - src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI). - .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully broken — $localize unresolved — dev + build). Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green, Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
64 lines
2.8 KiB
Plaintext
64 lines
2.8 KiB
Plaintext
import { Meta, Canvas } from '@storybook/addon-docs/blocks';
|
|
import * as AsyncStories from '../app/shared/ui/async/async.stories';
|
|
|
|
<Meta title="Foundations/FP in the UI" />
|
|
|
|
# Functional programming in the UI
|
|
|
|
The components in this library are the *view*. Behind them, three small functional tools do
|
|
the heavy lifting — all so that **illegal states can't be represented**. This page is the
|
|
Storybook front door; the full narrative lives in `docs/fp-tea-atomic-design.md`, and a
|
|
side-by-side "before/after" runs at the app's **`/concepts`** route.
|
|
|
|
## 1. `RemoteData<E,T>` — async has four states, not a boolean soup
|
|
|
|
`src/app/shared/application/remote-data.ts`. Instead of juggling `loading`, `error`, and
|
|
`data` flags (which permit "loading **and** error" nonsense), one tagged union:
|
|
`Loading | Empty | Failure | Success`. You combine sources with `map`/`map2`/`andThen` and
|
|
render it through the `async` molecule — exactly one of four templates shows, by
|
|
construction:
|
|
|
|
<Canvas of={AsyncStories.Loading} />
|
|
<Canvas of={AsyncStories.ErrorState} />
|
|
|
|
## 2. The Elm-style store — all state in one Model, changed only by pure `reduce`
|
|
|
|
`src/app/shared/application/store.ts` + the `*.machine.ts` files. State is one tagged-union
|
|
value; the template never mutates it, it `dispatch`es a message and a **pure**
|
|
`reduce(model, msg)` returns the next state. Side effects live in a *command*, never in the
|
|
reducer:
|
|
|
|
```ts
|
|
// reducer = "what the new state is" — pure, testable, no I/O
|
|
function reduce(model: Model, msg: Msg): Model { … }
|
|
|
|
// command = "go do it, then say what happened"
|
|
async function submit(...) {
|
|
const res = await http(...);
|
|
dispatch(res.ok ? { tag: 'Submitted' } : { tag: 'Failed', error: res.error });
|
|
}
|
|
```
|
|
|
|
Because state is one value, the whole thing is inspectable and every transition has a spec.
|
|
|
|
## 3. Parse, don't validate — raw input becomes a branded type once
|
|
|
|
`src/app/registratie/domain/value-objects/`. A `Postcode` is a distinct type from `string`,
|
|
mintable only through `parsePostcode`, which returns a `Result`. Once you hold the type, you
|
|
never re-check it — the type *is* the proof. Compose the parse pipeline with the `Result`
|
|
combinators in `src/app/shared/kernel/fp.ts` (`map`, `mapErr`, `andThen`, `fold`) rather than
|
|
hand-branching `r.ok ? … : …` at every step.
|
|
|
|
```ts
|
|
parsePostcode(raw) // Result<string, Postcode>
|
|
|> mapErr(toLocalizedMessage) // swap raw msg → UI copy
|
|
|> map(toDomain) // only runs on success
|
|
```
|
|
|
|
## How it connects to atomic design
|
|
|
|
Atoms and molecules are pure view functions of their inputs; pages are the TEA runtime (the
|
|
"shell") that holds the store and wires effects. Same inward-pointing discipline as the
|
|
[layer rule](?path=/docs/foundations-atomic-design--docs), applied to state and effects
|
|
instead of imports.
|