foldkit-crud 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,51 +1,82 @@
1
1
  # foldkit-crud
2
2
 
3
- The screens that create, read, update and delete, assembled from parts an
4
- application already has. They are for any such screen, a user editing their own
5
- post as much as a back office, and they draw nothing. An
6
- **editor** joins a [`foldkit-form`](../form/README.md) form, the
7
- [`foldkit-remote`](../remote/README.md) mutation its value feeds, and the
8
- [`foldkit-entity`](../entity/README.md) Entity they share. A **list** joins a
9
- Remote query and an Entity Selection, a **detail** reads one Entity through a
10
- Selection, and a **remover** deletes through a mutation with a yes in between.
3
+ The screens that list, show, edit and delete records, built from parts your
4
+ application already has: a [`foldkit-remote`](../remote/README.md) domain, the
5
+ [`foldkit-entity`](../entity/README.md) Entities it holds, and, for an editor, a
6
+ [`foldkit-form`](../form/README.md) form. It draws nothing and holds almost no
7
+ state. A list is one query and what to show of each row:
11
8
 
12
- > **Status:** an editor (edit and create), a list, a detail, and a remover. All
13
- > are headless, as the form is. [`examples/entity`](../../examples/entity) draws
14
- > them and runs in a browser.
9
+ ```ts
10
+ const Authors = Crud.list('Authors', {
11
+ query: AuthorsQuery,
12
+ selection: Entity.select(Blog.Author, { id: true, name: true }),
13
+ })
14
+ const AuthorList = Authors.at({ data: Data, input: model => Option.some({ search: model.search }) })
15
+
16
+ AuthorList.page(model) // RemoteData of the page: its typed rows, hasNext, hasPrevious
17
+ ```
18
+
19
+ It is for any such screen, a user editing their own post as much as a back
20
+ office. [`examples/entity`](../../examples/entity) draws a list, an editor and a
21
+ remover, and runs in a browser.
22
+
23
+ ## Why you would use it
24
+
25
+ An edit screen looks like a form plus a request. By hand it is more: load the
26
+ record when the screen opens, and keep it loaded while it is shown; fill the
27
+ form once when the value arrives, and never again over what the user typed;
28
+ turn a valid submit into the mutation, and remember which request is this
29
+ screen's; show loading, saving, saved and failed from that request; notice when
30
+ the record was deleted while it was open. A list needs its pages loaded,
31
+ retained and extended, its columns labelled, and its rows offered to a picker.
32
+
33
+ `foldkit-crud` does that glue once. What makes it safe to use is what it
34
+ leaves alone: the rows and the save state stay in Remote's store, the drafts
35
+ stay in the form, and every status a piece reports is derived from them, so it
36
+ cannot disagree with them.
37
+
38
+ ## The four pieces
39
+
40
+ | Piece | Joins | State of its own | Placed as |
41
+ | --- | --- | --- | --- |
42
+ | **list** | a Remote query and an Entity Selection | none: the pages are Remote's | a value; nothing to place |
43
+ | **detail** | an Entity Selection and an id the Model gives | none | a value |
44
+ | **editor** | a form, the mutation its value feeds, and their Entity | which id is open, whether it has been filled, which save is its own | a Bundle, like the form it wraps |
45
+ | **remover** | a delete mutation, with a yes in between | which id is being asked about, which delete is its own | a Bundle |
15
46
 
16
- ## What it owns
47
+ Nothing is generated from an Entity alone. An editor exists because you named
48
+ a form and a mutation, and a list because you named a query, which your server
49
+ authorizes.
17
50
 
18
- An editor adds no state system and no runtime. It is the glue between three
19
- things that each already own their part:
51
+ ## What it owns, and what it does not
20
52
 
21
53
  | Fact | Owner |
22
54
  | --- | --- |
23
- | The drafts and whether they are valid | the form |
24
- | The current values, and whether a save is pending, applied, or failed | Remote, in its store |
25
- | What to load, and how a loaded value becomes input values | the form's input (`Entity.selectFor`, `Entity.valuesFor`) |
26
- | Which id is being edited, whether it has been shown, which save is this editor's | the editor, in its slice of the parent Model |
27
- | What happens after a save (navigate, close, toast) | the application, reading `status` |
55
+ | The records, and whether a save or delete is pending, applied or failed | Remote, in its store |
56
+ | What a record is: its members, labels and relations | the Entity |
57
+ | The drafts, and whether they are valid | the form |
58
+ | Which id is open or being asked about, and which request is this screen's | the editor or remover, in its slice of your Model |
59
+ | Which rows a list shows: search, sort, filters | your Model, as the query's input |
60
+ | What happens after a save or delete: navigate, close, a toast | your application, reading the piece's status |
61
+ | How it looks | you, or [`foldkit-mixins-crud`](../mixins-crud/README.md) and [`foldkit-mixins-form`](../mixins-form/README.md) |
28
62
 
29
- Nothing is generated from an Entity alone. An editor exists because you named a
30
- form and a mutation; an Entity with neither has no editor.
31
-
32
- ## Mental model
63
+ ## The mental model
33
64
 
34
65
  ```text
35
- open(id)
36
- | the members the form writes become a requirement, like a Surface's
37
- v
38
- Remote fetches ──► the value arrives ──► sync fills the form, once
39
- |
40
- v
41
- the user edits ──► Submitted ──► the form hands over a decoded value
42
- |
43
- v
44
- onOut ──► Data.mutate ──► Remote settles it ──► status: Saving ─► Saved / SaveFailed
66
+ your Model says what is on screen
67
+ ──▶ each piece's `active`: what it requires of Remote
68
+ ──▶ Data.wiring ──▶ Remote fetches and retains it
69
+
70
+ Remote's store ──▶ each piece reads it: a page, a value, a status ──▶ your view
71
+
72
+ the user acts ──▶ a Message ──▶ a piece's helper, or the editor's onOut
73
+ ──▶ Data.mutate ──▶ Remote settles it ──▶ the status moves
45
74
  ```
46
75
 
47
- `status` is derived, not stored: it reads the editor's slice, Remote's mutation
48
- state, and the loaded value, so it cannot disagree with them.
76
+ The rule to remember: **a piece loads only what its `active` requires, and only
77
+ if that `active` is wired.** A piece that is not wired never loads, and nothing
78
+ reports it. [Wiring a page](#wiring-a-page) gathers every piece's `active` so
79
+ none is left out.
49
80
 
50
81
  ## Install
51
82
 
@@ -53,29 +84,90 @@ state, and the loaded value, so it cannot disagree with them.
53
84
  pnpm add effect foldkit foldkit-crud foldkit-bundle foldkit-entity foldkit-form foldkit-remote foldkit-surface
54
85
  ```
55
86
 
56
- ## Example
87
+ ## A first list
57
88
 
58
- `EditPostForm` is a `Form.make` result and `EditPostMutation` a Remote mutation
59
- over the same input struct, as in [`examples/entity`](../../examples/entity).
60
- `Blog` is that application's Entity domain. The example below is the page
61
- integration, not a definition of those domain values.
89
+ A list is one query and one Selection: the query decides which rows, the
90
+ Selection what to show of each.
91
+
92
+ ```ts
93
+ const Authors = Crud.list('Authors', {
94
+ query: AuthorsQuery, // Query.make('Authors', { Input: { search }, Result: Query.connection(Blog.Author) })
95
+ selection: Entity.select(Blog.Author, { id: true, name: true }),
96
+ pageSize: 25,
97
+ // How a row reads as a choice, for a list that feeds relation pickers.
98
+ choice: { value: row => row.id, label: row => row.name },
99
+ })
100
+
101
+ const AuthorList = Authors.at({
102
+ data: Data,
103
+ // The query's input as the Model has it; none while the list is not shown.
104
+ input: model => Option.map(Option.fromNullOr(model.search), search => ({ search })),
105
+ })
106
+
107
+ const subscriptions = Data.subscriptions({ authors: AuthorList.active })
108
+ ```
109
+
110
+ What each call does:
111
+
112
+ - **`Crud.list`** is a declaration: no request, no state. `Authors.columns` is
113
+ the selected members in the Selection's order, each with its `key`,
114
+ `member`, a `label` found the way a form finds one (the schema's `title`,
115
+ else `Form.label` metadata, else the key), and a `display`.
116
+ - **`Authors.at`** binds it to your Remote domain and your Model. It still does
117
+ no I/O.
118
+ - **`AuthorList.active`** is the requirement: while `input` gives a value, the
119
+ page and its rows are fetched and retained. Another input is another
120
+ connection, fetched on its own.
121
+ - **`AuthorList.page(model)`** reads Remote's store: a `RemoteData` of the page,
122
+ with `items` typed by the Selection, `hasNext` and `hasPrevious`.
123
+ - **`AuthorList.more(model)`** is "load more": the Model with the list showing
124
+ one page more, an `Option`, none when it already shows every row. Return it
125
+ from `update` (`Option.getOrElse(AuthorList.more(model), () => model)`); the
126
+ read entry fetches the rows it lacks.
127
+ - **`AuthorList.refresh(model)`** is the Model with the list asked for again. A
128
+ failed read is not retried on its own, so this is what a retry button's
129
+ Message returns.
130
+
131
+ `AuthorList.row(id)` is a Projection of one row through the list's Selection,
132
+ whether or not the query finds it now, and `AuthorList.choiceOf(model, id)` is
133
+ that row as a choice once it is read. Relation pickers use both.
134
+
135
+ ## Wiring a page
136
+
137
+ Every placed piece has an `active`. `Crud.actives` gathers them from the pieces
138
+ themselves, so a page cannot forget one:
139
+
140
+ ```ts
141
+ Data.wiring(Crud.actives({ posts: Posts, authors: Authors, editor: PostEditor, pickers }))
142
+ ```
143
+
144
+ It takes a placed editor, list or detail, and the pickers from `Crud.options`,
145
+ and gives `{ posts: Posts.active, ... }`, for `Data.wiring` in a
146
+ `foldkit-bundle` assembly, or for `Data.subscriptions`.
147
+
148
+ ## An editor
149
+
150
+ An editor joins a form, the mutation its value feeds, and the Entity they
151
+ share. `EditPostForm` is a `Form.make` result and `EditPostMutation` a Remote
152
+ mutation over the same input struct, as in
153
+ [`examples/entity`](../../examples/entity); `Blog` is that application's Entity
154
+ domain.
62
155
 
63
156
  ```ts
64
- import { Schema } from 'effect'
65
157
  import { Crud } from 'foldkit-crud'
66
158
  import { Bundle } from 'foldkit-bundle'
67
- import { defineMessageUnion } from 'foldkit/message'
68
159
  import { Remote, type RemoteClient } from 'foldkit-remote'
69
160
  import { Surface } from 'foldkit-surface'
70
161
 
71
162
  const Editor = Crud.editor('PostEditor', { form: EditPostForm, mutation: EditPostMutation })
72
163
 
73
164
  // The editor is a Submodel of the page: a Model field and a Message variant.
74
- const Slot = Bundle.declare(Editor.bundle, 'editor')
75
- const Model = Schema.Struct({ remote: Remote.Model, ...Slot.fields })
76
- const Message = defineMessageUnion({ ...Remote.messages, ...Slot.cases })
165
+ const Base = Bundle.compose({ remote: Remote.Model }).pipe(
166
+ Bundle.withMessages(Remote.messages),
167
+ Bundle.withChild('editor', Editor.bundle),
168
+ )
77
169
 
78
- const App = Surface.application({ Model, Message })
170
+ const App = Surface.application(Base)
79
171
  const Data = Remote.make({
80
172
  model: App.model.remote,
81
173
  entities: Object.values(Blog),
@@ -85,9 +177,13 @@ const Data = Remote.make({
85
177
  // Where it lives: its slice of the Model, and the domain it saves through.
86
178
  const PostEditor = Editor.at({ data: Data, model: App.model.editor })
87
179
 
88
- const Page = Bundle.parent({ Model, Message }).withServices<RemoteClient>()
89
- const Placed = Page.at(Slot, { onOut: PostEditor.onOut })
90
- const placements = Page.assemble(Placed)
180
+ // Its onOut needs the domain, so it is given once the domain exists.
181
+ const Page = Base.pipe(
182
+ Bundle.withServices<RemoteClient>(),
183
+ Bundle.configure('editor', { onOut: PostEditor.onOut }),
184
+ )
185
+ const Placed = Page.children.editor
186
+ const { placements } = Page
91
187
 
92
188
  const update = PostEditor.after(
93
189
  placements.update((model, message) =>
@@ -98,62 +194,48 @@ const update = PostEditor.after(
98
194
  const subscriptions = Data.subscriptions({ editor: PostEditor.active })
99
195
  ```
100
196
 
101
- - `Crud.editor` checks that the form's value is the mutation's input. Declaring
102
- the input struct once and giving it to both is what makes that true.
103
- - `Editor.bundle` wraps the form's Bundle. Its Messages are the form's own, so a
104
- view dispatches `EditPostForm.Message.Changed(...)` exactly as before.
105
- - `Editor.at` needs the editor's slice as a `ModelRef` (`App.model.editor`) and
106
- the bound Remote domain. It returns the pieces that need the parent:
197
+ - **`Crud.editor`** checks, in the types, that the form's value is the
198
+ mutation's input. Declaring the input struct once and giving it to both is
199
+ what makes that true.
200
+ - **`Editor.bundle`** wraps the form's Bundle. Its Messages are the form's own,
201
+ so a view dispatches `EditPostForm.Message.Changed(...)` exactly as before.
202
+ - **`Bundle.compose`** states the page once, with the editor under `editor`.
203
+ The editor's `onOut` is made from the Remote domain, which is made from the
204
+ page's own Model, so it is given afterwards with `Bundle.configure`; until
205
+ then the page's `placements` are a type error that names the editor. See
206
+ [composing a parent](../bundle/README.md#composing-a-parent).
207
+ - **`Editor.at`** takes the editor's slice as a `ModelRef` (`App.model.editor`)
208
+ and the bound Remote domain, and returns what needs the parent:
107
209
  - `onOut` for the placement: a valid submit becomes `Data.mutate`, and the
108
210
  editor remembers the request id.
109
- - `active`, an `ActiveSurface` for `Data.subscriptions`: while an id is open,
110
- what the form writes is fetched and retained like any Surface's requirement.
111
- - `after(update)`: wraps `update` so `sync` runs after every Message.
112
- - `sync`, the Step itself, if you compose `update` another way.
113
- - `status(model)`, `saveError(model)` while it is `SaveFailed`, `refresh(model)`
114
- to ask for the value again while it is `LoadFailed`, and
115
- `target(model)`, the id being edited (`null` for a new one or when closed).
116
- - The save is a Command that needs `RemoteClient`, so the parent scope names it
117
- with `withServices<RemoteClient>()`.
118
-
119
- ### What starts the work?
211
+ - `active`: while an id is open, what the form writes is fetched and
212
+ retained.
213
+ - `after(update)`: wraps `update` so the editor fills the form and
214
+ reconciles its status after every Message. `sync` is that Step on its own,
215
+ for an `update` composed another way.
216
+ - `status(model)`, `saveError(model)` while it is `SaveFailed`,
217
+ `refresh(model)` to ask for the value again while it is `LoadFailed`, and
218
+ `target(model)`, the id being edited (`null` for a new one, or when
219
+ closed).
220
+ - The save is a Command that needs `RemoteClient`, so the page names it with
221
+ `Bundle.withServices<RemoteClient>()`.
222
+
223
+ ### What starts the work
120
224
 
121
225
  | Call | What happens |
122
226
  | --- | --- |
123
- | `Crud.editor(...)` / `Editor.at(...)` | Build the editor and its parent integration; no request runs. |
124
- | `Placed.helpers.open(id)` | Return an Update.Step. Apply it inside your parent update to select a row. |
125
- | `Data.subscriptions({ editor: PostEditor.active })` | Describe the loader; the runtime must install it and provide `RemoteClient`. |
126
- | `PostEditor.after(update)` | Fill the form after loading and reconcile save status after Messages. |
127
- | Valid form submission through `onOut` | Return the mutation Command; its result comes back through Remote. |
227
+ | `Crud.editor(...)` / `Editor.at(...)` | Builds the editor and its integration; no request runs |
228
+ | `Placed.helpers.open(id)` | Returns an Update Step; apply it in your `update` to open a record |
229
+ | `Data.subscriptions({ editor: PostEditor.active })` | Describes the loader; the runtime installs it and provides `RemoteClient` |
230
+ | `PostEditor.after(update)` | Fills the form once the value loads, and reconciles the status after each Message |
231
+ | A valid submit, through `onOut` | Returns the mutation Command; its result comes back through Remote |
128
232
 
129
- Include the placement's own subscriptions as well if your form uses them.
130
- Do not interpret `Submitted` as `Saved`: only the returned mutation outcome
131
- settles the save. The status section below describes what the view should show.
132
-
133
- ### Drawing it
134
-
135
- The editor wraps the form's Model, so it takes the form's view, lifted:
136
-
137
- ```ts
138
- import { FormView } from 'foldkit-mixins-form'
139
-
140
- const Slot = Bundle.declare(
141
- Editor.bundle.pipe(
142
- Bundle.withView(Crud.editorView(FormView.submodel(EditPostForm, FormView.define(EditPostForm)))),
143
- ),
144
- 'editor',
145
- )
146
-
147
- // where the page draws it:
148
- Placed.view(model, h, { options: pickers(model), words: { submit: 'Save' } })
149
- ```
150
-
151
- `Crud.editorView` works on any Submodel view of the form that takes view
152
- inputs; `foldkit-crud` itself stays headless.
233
+ Include the placement's own Subscriptions as well if your form has any. Do not
234
+ read `Submitted` as `Saved`: only the mutation's outcome settles the save.
153
235
 
154
236
  ### Opening it
155
237
 
156
- `open`, `blank`, and `close` are helpers of the placement, so they are Update
238
+ `open`, `blank` and `close` are helpers of the placement, so they are Update
157
239
  Steps to use from your own `update`:
158
240
 
159
241
  ```ts
@@ -166,75 +248,117 @@ One editor serves one form. A create form and an edit form usually differ (the
166
248
  edit input has an `id`), so they are two editors; `blank` is for the one whose
167
249
  form creates.
168
250
 
169
- ## Status
251
+ ### Its status
170
252
 
171
253
  | Status | When |
172
254
  | --- | --- |
173
255
  | `Closed` | nothing is open |
174
256
  | `Loading` | an id is open and its current values have not arrived |
175
- | `NotFound` | it is gone: the server answered without it, a mutation deleted it, or a live event did. This outranks a save that landed |
176
- | `LoadFailed` | reading it failed, in the request or in decoding what came back, with no earlier value to show. A value whose later refresh failed fills the form as the last good one, and once filled the form stays `Editing` whatever a later read says. `refresh(model)` asks again |
177
257
  | `Editing` | the form is showing, with no save in progress or just settled |
178
258
  | `Saving` | this editor's mutation is pending |
179
259
  | `Saved` | it was applied |
180
260
  | `SaveFailed` | it failed; the drafts are kept, and `saveError(model)` says why |
261
+ | `LoadFailed` | reading it failed, with no earlier value to show; `refresh(model)` asks again |
262
+ | `NotFound` | it is gone: the server answered without it, a mutation deleted it, or a live event did. This outranks a save that landed |
181
263
 
182
- An edit after a save returns to `Editing`: the last save no longer describes what
183
- is in the form.
264
+ An edit after a save returns to `Editing`: the last save no longer describes
265
+ what is in the form.
184
266
 
185
- ## Behaviour worth knowing
267
+ ### How it treats the form
186
268
 
187
- - **The form is filled once.** When the value is read again (a refresh, a live
188
- update) the drafts are left alone, so nothing the user typed is overwritten.
189
- Opening the same or another id empties the form and fills it again.
190
- - **What is loaded is what the form writes**, with each relation as a ref. A key
191
- the form marks `Entity.unmapped` is not loaded and starts empty.
269
+ - **The form is filled once.** When the value is read again, by a refresh or a
270
+ live update, the drafts are left alone, so nothing the user typed is
271
+ overwritten. Opening the same or another id empties the form and fills it
272
+ again.
273
+ - **What is loaded is what the form writes**, with each relation as a ref. A
274
+ key the form marks `Entity.unmapped` is not loaded and starts empty.
192
275
  - **An invalid submit starts no mutation.** The form emits nothing until every
193
276
  key passes the input's schema and its checks. The form's Commands are the
194
277
  editor's, so a check the form starts runs.
195
278
 
196
- ## Lists
279
+ ### Drawing it
197
280
 
198
- A list is one query and one Selection: the query decides which rows, the
199
- Selection what to show of each. The pages live in Remote, so a list holds no
200
- state of its own and is not a Bundle.
281
+ The editor wraps the form's Model, so it takes the form's view, lifted:
201
282
 
202
283
  ```ts
203
- const Authors = Crud.list('Authors', {
204
- query: AuthorsQuery, // Query.make('Authors', { Input: { search }, Result: Query.connection(Blog.Author) })
205
- selection: Entity.select(Blog.Author, { id: true, name: true }),
206
- pageSize: 25,
207
- // How a row reads as a choice, for a list that feeds relation pickers.
208
- choice: { value: row => row.id, label: row => row.name },
284
+ import { FormView } from 'foldkit-mixins-form'
285
+
286
+ const DrawnEditor = Editor.bundle.pipe(
287
+ Bundle.withView(Crud.editorView(FormView.submodel(EditPostForm, FormView.define(EditPostForm)))),
288
+ )
289
+ // Place it as before: Bundle.withChild('editor', DrawnEditor)
290
+
291
+ // where the page draws it:
292
+ Placed.view(model, h, { options: pickers(model), words: { submit: 'Save' } })
293
+ ```
294
+
295
+ `Crud.editorView` works on any Submodel view of the form that takes view
296
+ inputs; `foldkit-crud` itself stays headless.
297
+
298
+ ## Deleting
299
+
300
+ A remover is a mutation with a yes in between. Like the editor it is a Bundle,
301
+ because which id is being asked about is state.
302
+
303
+ ```ts
304
+ const Remover = Crud.remover('PostRemover', {
305
+ mutation: DeletePostMutation,
306
+ input: id => ({ id }), // the mutation's input for an id
209
307
  })
308
+ // In the page: Bundle.withChild('remover', Remover.bundle), beside the editor.
210
309
 
211
- const AuthorList = Authors.at({
310
+ const PostRemover = Remover.at({ data: Data, model: App.model.remover })
311
+ // With the editor's: Bundle.configure('remover', { onOut: PostRemover.onOut })
312
+ const RemoveForm = Page.children.remover
313
+ ```
314
+
315
+ - `RemoveForm.helpers.ask(id)` asks; nothing is deleted yet. `dismiss()`
316
+ clears it.
317
+ - `Remover.Message.Confirmed()` and `Cancelled()` are the yes and the no. A
318
+ yes with nothing asked does nothing.
319
+ - `PostRemover.status(model)` is `Idle`, `Confirming`, `Deleting`, `Deleted`
320
+ or `DeleteFailed`; `target(model)` is the id, for the confirmation's words,
321
+ and `error(model)` says why a delete failed.
322
+
323
+ The server's mutation says what is gone, with `deleted` in its outcome, and
324
+ names no list. Remote then knows the record absent: it leaves every list and
325
+ relation it was in with no refetch, a detail of it reads `NotFound`, and an
326
+ editor open on it reports `NotFound`.
327
+
328
+ ## Detail
329
+
330
+ One record through a Selection, for a page that shows it. Like a list it holds
331
+ no state.
332
+
333
+ ```ts
334
+ const PostDetail = Crud.detail('PostDetail', { selection: PostPage }).at({
212
335
  data: Data,
213
- // The query's input as the Model has it; `undefined` while the list is not shown.
214
- input: model => (model.search === null ? undefined : { search: model.search }),
336
+ id: model => Option.fromNullOr(model.shownPostId), // none while none is shown
215
337
  })
216
338
 
217
- const subscriptions = Data.subscriptions({ authors: AuthorList.active })
339
+ PostDetail.value(model) // RemoteData of the Selection's value
218
340
  ```
219
341
 
220
- - `Authors.columns` is the selected members in the Selection's order, each with
221
- its `key`, `member`, a `label` found the way a form finds one (the schema's
222
- `title`, else `Form.label` metadata, else the key), and a `display`.
223
- - `AuthorList.page(model)` is a `RemoteData` of the page: `items` typed by the
224
- Selection, `hasNext`, `hasPrevious`.
225
- - `AuthorList.more(model)` is the Command that loads the next page onto this one,
226
- or `undefined` when there is none. Return it from `update`.
227
- - `AuthorList.refresh(model)` is the Model with the list asked for again:
228
- `Data.refresh` over its page and rows. A failed read is not retried on its
229
- own, so this is what a retry button's Message returns. A placed detail has the
230
- same `refresh`.
231
- - `AuthorList.row(id)` is a Projection of one row through the list's Selection,
232
- whether or not the query finds it now, and `AuthorList.choiceOf(model, id)` is
233
- that row as a choice once it is read. `Crud.options` uses both for a picker
234
- that searches.
235
- - `AuthorList.active` makes the page and its rows a requirement while `input`
236
- gives a value, so Remote fetches and retains them. Another input is another
237
- connection.
342
+ `Crud.detail(...).fields` lists the selected members with their labels, as a
343
+ list's `columns` does. `PostDetail.active` makes the value a requirement while
344
+ an id is shown, and `PostDetail.refresh(model)` asks for it again.
345
+
346
+ ## When something fails
347
+
348
+ Nothing here retries on its own: a failure stays visible until your
349
+ application asks again.
350
+
351
+ | What happened | What the piece reports | What to do |
352
+ | --- | --- | --- |
353
+ | A list's page could not be read | `page(model)` is `Failed`, with `previous` rows if it had some | `refresh(model)` from a retry button |
354
+ | A detail's value could not be read | `value(model)` is `Failed` | `refresh(model)` |
355
+ | An editor's record could not be read | `LoadFailed`; a record read before and failed on refresh stays `Editing`, showing the last good value | `refresh(model)` |
356
+ | A save failed | `SaveFailed`; the drafts are kept | show `saveError(model)`; the user submits again |
357
+ | A delete failed | `DeleteFailed` | show `error(model)`; ask again |
358
+ | The record was deleted, here or elsewhere | a detail reads `NotFound`, an open editor reports `NotFound`, and it leaves every list | close the screen or say so |
359
+ | A piece's `active` is not wired | it stays `Initial` or `Loading`, and nothing reports it | wire every piece with [`Crud.actives`](#wiring-a-page) |
360
+
361
+ ## Showing values
238
362
 
239
363
  ### How a column shows
240
364
 
@@ -260,6 +384,13 @@ Display.show(column.display, row[column.key], { yes: 'Live', no: 'Draft' }) // t
260
384
  | `Ref` | a relation selected with `true`: its id, or ids |
261
385
  | `Nested` | a relation read through a Selection: the target's own columns, with `shape` `one`, `many`, or `page` |
262
386
 
387
+ A display is resolved from the most to the least explicit source: `Display.of`
388
+ metadata on the member, how the relation was selected, then the shape of the
389
+ schema. `Display.show` is the text any view can fall back on; a view that wants
390
+ a link or a badge reads `display` and the value.
391
+ [`foldkit-mixins-crud`](../mixins-crud/README.md) draws a list as a table and a
392
+ detail as a description list from exactly this.
393
+
263
394
  There is one primitive, as with a form's controls: a `kind`, whether it is
264
395
  `shown`, its `data`, and its `text`. The kinds above are made with
265
396
  `Display.kind`, and so is yours:
@@ -272,23 +403,16 @@ const Badge = Display.kind<{ readonly tone: string }>('Badge', {
272
403
  const Post = Blog.Post.pipe(Entity.annotateMembers({ status: Display.of(Badge.of({ tone: 'soft' })) }))
273
404
  ```
274
405
 
275
- `Badge.is(display)` narrows `display.data`. A drawn view draws it specially once
276
- given a renderer for `Badge`.
277
-
278
- It is resolved from the most to the least explicit source: `Display.of`
279
- metadata on the member, how the relation was selected, then the shape of the
280
- schema. `Display.show` is the text any view can fall back on; a view that wants
281
- a link or a badge reads `display` and the value.
282
- [`foldkit-mixins-crud`](../mixins-crud/README.md) draws a list as a table and a
283
- detail as a description list from exactly this.
406
+ `Badge.is(display)` narrows `display.data`. A drawn view draws it specially
407
+ once given a renderer for `Badge`.
284
408
 
285
409
  ### A relation picker's choices
286
410
 
287
411
  A form names a relation's target Entity and leaves listing it to you, because a
288
412
  relationship existing is no licence to read a table. A list is that licence: a
289
- query you declared and your server authorizes. A list with a `choice` offers its
290
- loaded rows as choices, and `Crud.options` hands each picker of a form the list
291
- over its target:
413
+ query you declared and your server authorizes. A list with a `choice` offers
414
+ its loaded rows as choices, and `Crud.options` hands each picker of a form the
415
+ list over its target:
292
416
 
293
417
  ```ts
294
418
  const pickers = Crud.options(EditPostForm, [AuthorList])
@@ -296,13 +420,21 @@ const pickers = Crud.options(EditPostForm, [AuthorList])
296
420
  Placed.view(model, h, { options: pickers(model) }) // with foldkit-mixins-form
297
421
  ```
298
422
 
423
+ `pickers(model)` is keyed by the form's keys:
424
+ `{ authorId: [{ value, label }, …] }`. A list is matched to a picker by
425
+ Entity, so one author list serves `authorId` and `editorId` alike. A picker
426
+ whose target no list is over, or whose list has no `choice`, throws when
427
+ `Crud.options` is called, not when the form is drawn. `AuthorList.choices(model)`
428
+ is one list's choices on its own. Both are empty until the page is loaded, and
429
+ hold only the rows loaded so far.
430
+
299
431
  #### When a picker searches
300
432
 
301
433
  ```ts
302
434
  const AuthorList = Authors.at({
303
435
  data: Data,
304
436
  // What the picker's search box holds is the form's; here it is the query's input.
305
- input: model => ({ search: EditPostForm.search(model.editor.form, 'authorId') }),
437
+ input: model => Option.some({ search: EditPostForm.search(model.editor.form, 'authorId') }),
306
438
  })
307
439
 
308
440
  const pickers = Crud.options(EditPostForm, [AuthorList], {
@@ -318,74 +450,10 @@ in it. With `chosen`, those rows stay among the choices, first, and
318
450
  named. Without it a `select` would show a post's author as blank the moment a
319
451
  search stopped finding them.
320
452
 
321
- `pickers(model)` is keyed by the form's keys: `{ authorId: [{ value, label }, …] }`.
322
- A list is matched to a picker by Entity, so one author list serves `authorId`
323
- and `editorId` alike. A picker whose target no list is over, or whose list has no `choice`, throws
324
- when `Crud.options` is called, not when the form is drawn. `AuthorList.choices(model)`
325
- is one list's choices on its own. Both are empty until the page is loaded, and
326
- hold only the rows loaded so far.
327
-
328
- ## Deleting
329
-
330
- A remover is a mutation with a yes in between. Like the editor it is a Bundle,
331
- because which id is being asked about is state.
332
-
333
- ```ts
334
- const Remover = Crud.remover('PostRemover', {
335
- mutation: DeletePostMutation,
336
- input: id => ({ id }), // the mutation's input for an id
337
- })
338
- const RemoveSlot = Bundle.declare(Remover.bundle, 'remover')
339
- // ...spread RemoveSlot.fields and RemoveSlot.cases into the page's Model and Message...
340
-
341
- const PostRemover = Remover.at({ data: Data, model: App.model.remover })
342
- const RemoveForm = Page.at(RemoveSlot, { onOut: PostRemover.onOut })
343
- ```
344
-
345
- - `RemoveForm.helpers.ask(id)` asks; nothing is deleted yet. `dismiss()` clears it.
346
- - `Remover.Message.Confirmed()` and `Cancelled()` are the yes and the no. A yes
347
- with nothing asked does nothing.
348
- - `PostRemover.status(model)` is `Idle`, `Confirming`, `Deleting`, `Deleted`, or
349
- `DeleteFailed`; `target(model)` is the id for the confirmation's words, and
350
- `error(model)` says why a delete failed.
351
-
352
- The server's mutation says what is gone, with `deleted` in its outcome, and
353
- names no list. Remote then knows the entity absent: it leaves every list and
354
- relation it was in with no refetch, a detail of it reads `NotFound`, and an
355
- editor open on it reports `NotFound`.
356
-
357
- ## Detail
358
-
359
- One Entity through a Selection, for a page that shows it. Like a list it holds no
360
- state.
361
-
362
- ```ts
363
- const PostDetail = Crud.detail('PostDetail', { selection: PostPage }).at({
364
- data: Data,
365
- id: model => model.shownPostId ?? undefined, // `undefined` while none is shown
366
- })
367
-
368
- PostDetail.value(model) // RemoteData of the Selection's value
369
- ```
370
-
371
- `Crud.detail(...).fields` lists the selected members with their labels, as a
372
- list's `columns` does, and `PostDetail.active` makes the value a requirement
373
- while an id is shown.
374
-
375
- ## Typed ids
376
-
377
- When the Entity's `id` is branded, the ids here are too. An editor's
378
- `helpers.open(id)` takes the `IdOf` of its form's Entity, and `target(model)`
379
- reads the id being edited as that type. A remover whose mutation's input is just the id names the key, `id: 'id'`, and is
380
- asked about that key's type; with a fuller input, annotate the function:
381
- `input: (id: PostId) => ({ id, reason: 'spam' })`. A list's rows
382
- are its Selection's own value, so `row.id` is a `PostId` when the Selection
383
- reads `id`.
384
-
385
453
  ## Sorting a list
386
454
 
387
455
  How a list is sorted is your state and the query's input. `Sort` writes that
388
- state down once, so the Model, the header clicks, and the server agree:
456
+ state down once, so the Model, the header clicks and the server agree:
389
457
 
390
458
  ```ts
391
459
  const PostSort = Sort.make(['title', 'created']) // the orders offered, by name
@@ -405,30 +473,27 @@ PostSort.inputs(model.postSort, sort => Message.SortedPosts({ sort })) // a draw
405
473
  the server, `foldkit-remote-drizzle`'s `sortTerms(sort, { title: posts.title })`
406
474
  says what each name means and ignores any other.
407
475
 
408
- ## Wiring a page
409
-
410
- Every placed piece has an `active`: what it requires of Remote while it is on
411
- screen. A piece whose `active` is not wired never loads, and says nothing.
412
- `Crud.actives` gathers them from the pieces themselves:
413
-
414
- ```ts
415
- Data.wiring(Crud.actives({ posts: Posts, authors: Authors, editor: PostEditor, pickers }))
416
- ```
476
+ ## Typed ids
417
477
 
418
- It takes a placed editor, list, or detail, and the pickers from `Crud.options`,
419
- and gives `{ posts: Posts.active, ... }`, for `Data.wiring` or
420
- `Data.subscriptions`.
478
+ When the Entity's `id` is branded, the ids here are too. An editor's
479
+ `helpers.open(id)` takes the `IdOf` of its form's Entity, and `target(model)`
480
+ reads the id being edited as that type. A remover whose mutation's input is
481
+ just the id names the key, `id: 'id'`, and is asked about that key's type; with
482
+ a fuller input, annotate the function:
483
+ `input: (id: PostId) => ({ id, reason: 'spam' })`. A list's rows are its
484
+ Selection's own value, so `row.id` is a `PostId` when the Selection reads `id`.
421
485
 
422
486
  ## Limits
423
487
 
424
- - A list has no sorting, filtering, or selection state of its own: those are the
425
- query's input, which your Model holds. Change the input and the list is another
426
- connection, fetched because it is on screen; `foldkit-remote-drizzle`'s `query`
427
- reads the input in `where` and `orderBy`. [`examples/entity`](../../examples/entity)
428
- searches and sorts its post list this way.
429
- - Headless. Draw a list or a detail with
488
+ - **A list has no sorting, filtering or selection state of its own.** Those are
489
+ the query's input, which your Model holds. Change the input and the list is
490
+ another connection, fetched because it is on screen; `foldkit-remote-drizzle`'s
491
+ `query` reads the input in `where` and `orderBy`.
492
+ [`examples/entity`](../../examples/entity) searches and sorts its post list
493
+ this way.
494
+ - **Headless.** Draw a list or a detail with
430
495
  [`foldkit-mixins-crud`](../mixins-crud/README.md), or from `columns` and
431
496
  `Display.show`. Draw the editor with `Crud.editorView` over a
432
497
  [`foldkit-mixins-form`](../mixins-form/README.md) view, or from
433
- `EditPostForm.controls`. [`examples/entity`](../../examples/entity) draws a
434
- list and an editor, and runs in a browser.
498
+ `EditPostForm.controls`.
499
+ - **One editor per form.** A create screen and an edit screen are two editors.