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 +302 -237
- package/dist/index.d.mts +19 -32
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +57 -67
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -11
package/README.md
CHANGED
|
@@ -1,51 +1,82 @@
|
|
|
1
1
|
# foldkit-crud
|
|
2
2
|
|
|
3
|
-
The screens that
|
|
4
|
-
application already has
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
-
| Which id is
|
|
27
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
the user
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
##
|
|
87
|
+
## A first list
|
|
57
88
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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(
|
|
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
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
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
|
-
-
|
|
102
|
-
the input struct once and giving it to both is
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
110
|
-
|
|
111
|
-
- `after(update)`: wraps `update` so
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
`
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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(...)` |
|
|
124
|
-
| `Placed.helpers.open(id)` |
|
|
125
|
-
| `Data.subscriptions({ editor: PostEditor.active })` |
|
|
126
|
-
| `PostEditor.after(update)` |
|
|
127
|
-
|
|
|
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
|
|
130
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
267
|
+
### How it treats the form
|
|
186
268
|
|
|
187
|
-
- **The form is filled once.** When the value is read again
|
|
188
|
-
update
|
|
189
|
-
Opening the same or another id empties the form and fills it
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
279
|
+
### Drawing it
|
|
197
280
|
|
|
198
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
339
|
+
PostDetail.value(model) // RemoteData of the Selection's value
|
|
218
340
|
```
|
|
219
341
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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
|
|
290
|
-
loaded rows as choices, and `Crud.options` hands each picker of a form the
|
|
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
|
|
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
|
-
##
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
425
|
-
query's input, which your Model holds. Change the input and the list is
|
|
426
|
-
connection, fetched because it is on screen; `foldkit-remote-drizzle`'s
|
|
427
|
-
reads the input in `where` and `orderBy`.
|
|
428
|
-
searches and sorts its post list
|
|
429
|
-
|
|
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`.
|
|
434
|
-
|
|
498
|
+
`EditPostForm.controls`.
|
|
499
|
+
- **One editor per form.** A create screen and an edit screen are two editors.
|