foldkit-crud 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Glenn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,287 @@
1
+ # foldkit-crud
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.
11
+
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.
15
+
16
+ ## What it owns
17
+
18
+ An editor adds no state system and no runtime. It is the glue between three
19
+ things that each already own their part:
20
+
21
+ | Fact | Owner |
22
+ | --- | --- |
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` |
28
+
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
33
+
34
+ ```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
45
+ ```
46
+
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.
49
+
50
+ ## Install
51
+
52
+ ```sh
53
+ pnpm add effect foldkit foldkit-crud foldkit-bundle foldkit-entity foldkit-form foldkit-remote foldkit-surface
54
+ ```
55
+
56
+ ## Example
57
+
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
+
61
+ ```ts
62
+ import { Crud } from 'foldkit-crud'
63
+ import { Bundle } from 'foldkit-bundle'
64
+ import { defineMessageUnion } from 'foldkit/message'
65
+ import { Remote, type RemoteClient } from 'foldkit-remote'
66
+ import { Surface } from 'foldkit-surface'
67
+
68
+ const Editor = Crud.editor('PostEditor', { form: EditPostForm, mutation: EditPostMutation })
69
+
70
+ // The editor is a Submodel of the page: a Model field and a Message variant.
71
+ const Slot = Bundle.declare(Editor.bundle, 'editor')
72
+ const Model = Schema.Struct({ remote: Remote.Model, ...Slot.fields })
73
+ const Message = defineMessageUnion({ ...Remote.messages, ...Slot.cases })
74
+
75
+ const App = Surface.application({ Model, Message })
76
+ const Data = Remote.make({
77
+ model: App.model.remote,
78
+ entities: Object.values(Blog),
79
+ mutations: [EditPostMutation],
80
+ })
81
+
82
+ // Where it lives: its slice of the Model, and the domain it saves through.
83
+ const PostEditor = Editor.at({ data: Data, model: App.model.editor })
84
+
85
+ const Page = Bundle.parent({ Model, Message }).withServices<RemoteClient>()
86
+ const Placed = Page.at(Slot, { onOut: PostEditor.onOut })
87
+ const placements = Page.assemble(Placed)
88
+
89
+ const update = PostEditor.after(
90
+ placements.update((model, message) =>
91
+ Remote.reduces(message) ? { model: Data.reduce(model, message) } : { model },
92
+ ),
93
+ )
94
+
95
+ const subscriptions = Data.subscriptions({ editor: PostEditor.active })
96
+ ```
97
+
98
+ - `Crud.editor` checks that the form's value is the mutation's input. Declaring
99
+ the input struct once and giving it to both is what makes that true.
100
+ - `Editor.bundle` wraps the form's Bundle. Its Messages are the form's own, so a
101
+ view dispatches `EditPostForm.Message.Changed(...)` exactly as before.
102
+ - `Editor.at` needs the editor's slice as a `ModelRef` (`App.model.editor`) and
103
+ the bound Remote domain. It returns the pieces that need the parent:
104
+ - `onOut` for the placement: a valid submit becomes `Data.mutate`, and the
105
+ editor remembers the request id.
106
+ - `active`, an `ActiveSurface` for `Data.subscriptions`: while an id is open,
107
+ what the form writes is fetched and retained like any Surface's requirement.
108
+ - `after(update)`: wraps `update` so `sync` runs after every Message.
109
+ - `sync`, the Step itself, if you compose `update` another way.
110
+ - `status(model)`, and `saveError(model)` while it is `SaveFailed`.
111
+ - The save is a Command that needs `RemoteClient`, so the parent scope names it
112
+ with `withServices<RemoteClient>()`.
113
+
114
+ ### Drawing it
115
+
116
+ The editor wraps the form's Model, so it takes the form's view, lifted:
117
+
118
+ ```ts
119
+ import { FormView } from 'foldkit-mixins-form'
120
+
121
+ const Slot = Bundle.declare(
122
+ Editor.bundle.pipe(
123
+ Bundle.withView(Crud.editorView(FormView.submodel(EditPostForm, FormView.define(EditPostForm)))),
124
+ ),
125
+ 'editor',
126
+ )
127
+
128
+ // where the page draws it:
129
+ Placed.view(model, h, { options: pickers(model), submitLabel: 'Save' })
130
+ ```
131
+
132
+ `Crud.editorView` works on any Submodel view of the form that takes view
133
+ inputs; `foldkit-crud` itself stays headless.
134
+
135
+ ### Opening it
136
+
137
+ `open`, `blank`, and `close` are helpers of the placement, so they are Update
138
+ Steps to use from your own `update`:
139
+
140
+ ```ts
141
+ Placed.helpers.open('p2') // edit p2: the form empties, then fills when the value arrives
142
+ Placed.helpers.blank() // a new one: nothing to load, editing at once
143
+ Placed.helpers.close()
144
+ ```
145
+
146
+ One editor serves one form. A create form and an edit form usually differ (the
147
+ edit input has an `id`), so they are two editors; `blank` is for the one whose
148
+ form creates.
149
+
150
+ ## Status
151
+
152
+ | Status | When |
153
+ | --- | --- |
154
+ | `Closed` | nothing is open |
155
+ | `Loading` | an id is open and its current values have not arrived |
156
+ | `NotFound` | it is gone: the server answered without it, a mutation deleted it, or a live event did. This outranks a save that landed |
157
+ | `LoadFailed` | reading it failed to decode |
158
+ | `Editing` | the form is showing, with no save in progress or just settled |
159
+ | `Saving` | this editor's mutation is pending |
160
+ | `Saved` | it was applied |
161
+ | `SaveFailed` | it failed; the drafts are kept, and `saveError(model)` says why |
162
+
163
+ An edit after a save returns to `Editing`: the last save no longer describes what
164
+ is in the form.
165
+
166
+ ## Behaviour worth knowing
167
+
168
+ - **The form is filled once.** When the value is read again (a refresh, a live
169
+ update) the drafts are left alone, so nothing the user typed is overwritten.
170
+ Opening the same or another id empties the form and fills it again.
171
+ - **What is loaded is what the form writes**, with each relation as a ref. A key
172
+ the form marks `Entity.unmapped` is not loaded and starts empty.
173
+ - **An invalid submit starts no mutation.** The form emits nothing until every
174
+ key passes the input's schema and its checks. The form's Commands are the
175
+ editor's, so a check the form starts runs.
176
+
177
+ ## Lists
178
+
179
+ A list is one query and one Selection: the query decides which rows, the
180
+ Selection what to show of each. The pages live in Remote, so a list holds no
181
+ state of its own and is not a Bundle.
182
+
183
+ ```ts
184
+ const Authors = Crud.list('Authors', {
185
+ query: AuthorsQuery, // Query.make('Authors', { Input: { search }, Result: Query.connection(Blog.Author) })
186
+ selection: Entity.select(Blog.Author, { id: true, name: true }),
187
+ pageSize: 25,
188
+ // How a row reads as a choice, for a list that feeds relation pickers.
189
+ choice: { value: row => row.id, label: row => row.name },
190
+ })
191
+
192
+ const AuthorList = Authors.at({
193
+ data: Data,
194
+ // The query's input as the Model has it; `undefined` while the list is not shown.
195
+ input: model => (model.search === null ? undefined : { search: model.search }),
196
+ })
197
+
198
+ const subscriptions = Data.subscriptions({ authors: AuthorList.active })
199
+ ```
200
+
201
+ - `Authors.columns` is the selected members in the Selection's order, each with
202
+ its `key`, `member`, and a `label` found the way a form finds one: the schema's
203
+ `title`, else `Form.label` metadata, else the key.
204
+ - `AuthorList.page(model)` is a `RemoteData` of the page: `items` typed by the
205
+ Selection, `hasNext`, `hasPrevious`.
206
+ - `AuthorList.more(model)` is the Command that loads the next page onto this one,
207
+ or `undefined` when there is none. Return it from `update`.
208
+ - `AuthorList.active` makes the page and its rows a requirement while `input`
209
+ gives a value, so Remote fetches and retains them. Another input is another
210
+ connection.
211
+
212
+ ### A relation picker's choices
213
+
214
+ A form names a relation's target Entity and leaves listing it to you, because a
215
+ relationship existing is no licence to read a table. A list is that licence: a
216
+ query you declared and your server authorizes. A list with a `choice` offers its
217
+ loaded rows as choices, and `Crud.options` hands each picker of a form the list
218
+ over its target:
219
+
220
+ ```ts
221
+ const pickers = Crud.options(EditPostForm, [AuthorList])
222
+
223
+ Placed.view(model, h, { options: pickers(model) }) // with foldkit-mixins-form
224
+ ```
225
+
226
+ `pickers(model)` is keyed by the form's keys: `{ authorId: [{ value, label }, …] }`.
227
+ A list is matched to a picker by Entity, so one author list serves `authorId`
228
+ and `editorId` alike. A picker whose target no list is over, or whose list has no `choice`, throws
229
+ when `Crud.options` is called, not when the form is drawn. `AuthorList.choices(model)`
230
+ is one list's choices on its own. Both are empty until the page is loaded, and
231
+ hold only the rows loaded so far.
232
+
233
+ ## Deleting
234
+
235
+ A remover is a mutation with a yes in between. Like the editor it is a Bundle,
236
+ because which id is being asked about is state.
237
+
238
+ ```ts
239
+ const Remover = Crud.remover('PostRemover', {
240
+ mutation: DeletePostMutation,
241
+ input: id => ({ id }), // the mutation's input for an id
242
+ })
243
+ const RemoveSlot = Bundle.declare(Remover.bundle, 'remover')
244
+ // ...spread RemoveSlot.fields and RemoveSlot.cases into the page's Model and Message...
245
+
246
+ const PostRemover = Remover.at({ data: Data, model: App.model.remover })
247
+ const RemoveForm = Page.at(RemoveSlot, { onOut: PostRemover.onOut })
248
+ ```
249
+
250
+ - `RemoveForm.helpers.ask(id)` asks; nothing is deleted yet. `dismiss()` clears it.
251
+ - `Remover.Message.Confirmed()` and `Cancelled()` are the yes and the no. A yes
252
+ with nothing asked does nothing.
253
+ - `PostRemover.status(model)` is `Idle`, `Confirming`, `Deleting`, `Deleted`, or
254
+ `DeleteFailed`; `target(model)` is the id for the confirmation's words, and
255
+ `error(model)` says why a delete failed.
256
+
257
+ The server's mutation says what is gone, with `deleted` in its outcome, and
258
+ names no list. Remote then knows the entity absent: it leaves every list and
259
+ relation it was in with no refetch, a detail of it reads `NotFound`, and an
260
+ editor open on it reports `NotFound`.
261
+
262
+ ## Detail
263
+
264
+ One Entity through a Selection, for a page that shows it. Like a list it holds no
265
+ state.
266
+
267
+ ```ts
268
+ const PostDetail = Crud.detail('PostDetail', { selection: PostPage }).at({
269
+ data: Data,
270
+ id: model => model.shownPostId ?? undefined, // `undefined` while none is shown
271
+ })
272
+
273
+ PostDetail.value(model) // RemoteData of the Selection's value
274
+ ```
275
+
276
+ `Crud.detail(...).fields` lists the selected members with their labels, as a
277
+ list's `columns` does, and `PostDetail.active` makes the value a requirement
278
+ while an id is shown.
279
+
280
+ ## Limits
281
+
282
+ - A list has no sorting, filtering, or selection state of its own: those are the
283
+ query's input, which your Model holds.
284
+ - Headless. Draw the editor with `Crud.editorView` over a
285
+ [`foldkit-mixins-form`](../mixins-form/README.md) view, or from
286
+ `EditPostForm.controls`. [`examples/entity`](../../examples/entity) draws a
287
+ list and an editor, and runs in a browser.
@@ -0,0 +1,318 @@
1
+ import { Schema } from "effect";
2
+ import { Bundle } from "foldkit-bundle";
3
+ import { AnyEntity, EntityInput, EntityMember, Selection } from "foldkit-entity";
4
+ import { FormControl, Submitted } from "foldkit-form";
5
+ import * as Submodel from "foldkit/submodel";
6
+ import { MutationDescriptor, MutationStatus, Page, QueryDescriptor, RemoteClient, RemoteData, RemoteError, RemoteMessage } from "foldkit-remote";
7
+ import { ModelRef, Projection } from "foldkit-surface";
8
+ import { Command } from "foldkit/command";
9
+ import * as Update from "foldkit/update";
10
+ //#region src/index.d.ts
11
+ /** The parts of a `Form.make` result an editor wraps. */
12
+ export interface EditableForm<Name extends string, FormModel, FormMessage, Value, E extends AnyEntity, Fields extends Schema.Struct.Fields, Members, R = never> {
13
+ readonly bundle: {
14
+ readonly name: Name;
15
+ readonly Model: Schema.Codec<FormModel, unknown>;
16
+ readonly Message: Schema.Codec<FormMessage, unknown>;
17
+ readonly update: (model: FormModel, message: FormMessage, args: void) => Update.ReturnWithOutMessage<FormModel, FormMessage, Submitted<Value>, R>;
18
+ };
19
+ readonly input: EntityInput<E, Fields, Members>;
20
+ readonly initial: FormModel;
21
+ readonly fill: (model: FormModel, values: Partial<Value>) => {
22
+ readonly model: FormModel;
23
+ };
24
+ }
25
+ /**
26
+ * An editor's slice of the parent Model. `target` is the id being edited, or
27
+ * `null` for a new one; `filled` is whether the loaded value has been shown;
28
+ * `requestId` is the save in progress or last settled.
29
+ */
30
+ export interface EditorModel<FormModel> {
31
+ readonly form: FormModel;
32
+ readonly mode: 'closed' | 'new' | 'edit';
33
+ readonly target: string | null;
34
+ readonly filled: boolean;
35
+ readonly requestId: string | null;
36
+ }
37
+ /**
38
+ * A remover's slice of the parent Model: the id it was asked to delete and is
39
+ * waiting on a yes for, and the delete in progress or last settled.
40
+ */
41
+ export interface RemoverModel {
42
+ readonly target: string | null;
43
+ readonly requestId: string | null;
44
+ }
45
+ /** A remover's out Message: the user said yes to deleting `id`. */
46
+ export interface ConfirmedRemoval {
47
+ readonly _tag: 'Confirmed';
48
+ readonly id: string;
49
+ }
50
+ export type RemoverStatus = 'Idle' |
51
+ /** Asked to delete an id, and waiting for a yes or a no. */
52
+ 'Confirming' | 'Deleting' | 'Deleted' | 'DeleteFailed';
53
+ export type EditorStatus = 'Closed' |
54
+ /** Editing an id whose current values have not arrived. */
55
+ 'Loading' | 'NotFound' | 'LoadFailed' | 'Editing' | 'Saving' | 'Saved' | 'SaveFailed';
56
+ /** The parts of a bound Remote domain an editor uses. */
57
+ export interface DomainLike<Root> {
58
+ mutate(model: Root, mutation: any, input: any): {
59
+ readonly model: Root;
60
+ readonly requestId: string;
61
+ readonly command: Command<RemoteMessage, never, RemoteClient>;
62
+ };
63
+ get(selection: any, id: string): Projection<Root, RemoteData<any>>;
64
+ query(query: any, input: any, options: any): Projection<Root, RemoteData<Page<any>>>;
65
+ next(model: Root, projection: any): {
66
+ readonly query: string;
67
+ } | undefined;
68
+ fetch(ref: any): Command<RemoteMessage, never, RemoteClient>;
69
+ mutation(model: Root, requestId: string): MutationStatus;
70
+ readonly contract: {
71
+ readonly owner?: object | undefined;
72
+ };
73
+ }
74
+ type Step<Root> = Update.Step<Root, RemoteMessage, RemoteClient>;
75
+ /** One thing a relation picker offers. */
76
+ export interface Choice {
77
+ readonly value: string;
78
+ readonly label: string;
79
+ }
80
+ /** One column of a list, or one line of a detail: a member the Selection reads. */
81
+ export interface ListColumn<Key extends string = string> {
82
+ readonly key: Key;
83
+ /** The schema's `title` annotation, else `Form.label` metadata, else the key. */
84
+ readonly label: string;
85
+ readonly member: EntityMember;
86
+ }
87
+ export declare const Crud: {
88
+ /**
89
+ * One Entity read through a Selection, for a page that shows it. Like a list
90
+ * it holds no state: the value is Remote's and the id is the application's.
91
+ */
92
+ detail: <const Name extends string, EntityName extends string, Members, Row>(name: Name, config: {
93
+ readonly selection: Selection<EntityName, Members, Schema.Constraint & {
94
+ readonly Type: Row;
95
+ }>;
96
+ }) => {
97
+ name: Name;
98
+ /** The selected members in the Selection's order, each with its label. */
99
+ fields: readonly ListColumn<keyof Members & string>[];
100
+ /** The detail where it lives: `id` is the one shown, or `undefined` while none is. */
101
+ at: <Root>(where: {
102
+ readonly data: DomainLike<Root>;
103
+ readonly id: (root: Root) => string | undefined;
104
+ }) => {
105
+ /** For `Data.subscriptions`: the value is fetched and retained while an id is shown. */
106
+ active: {
107
+ name: Name;
108
+ owner: object;
109
+ projectionOf: (root: Root) => Projection<Root, RemoteData<any>> | undefined;
110
+ };
111
+ value: (root: Root) => RemoteData<Row>;
112
+ };
113
+ };
114
+ /**
115
+ * Deleting through one mutation, with a yes in between: asked, confirmed,
116
+ * deleted. What the mutation's input is for an id is `input`'s to say.
117
+ */
118
+ remover: <const Name extends string, Input>(name: Name, config: {
119
+ readonly mutation: MutationDescriptor<string, Input, any>;
120
+ readonly input: (id: string) => Input;
121
+ }) => {
122
+ bundle: Bundle.Bundle<Name, void, RemoverModel, {
123
+ readonly _tag: "Confirmed";
124
+ } | {
125
+ readonly _tag: "Cancelled";
126
+ }, ConfirmedRemoval, never, never, void, {}, {
127
+ /** Asks whether to delete `id`. Nothing is deleted until `Confirmed`. */
128
+ ask: (_: RemoverModel, id: string) => {
129
+ model: {
130
+ target: string;
131
+ requestId: null;
132
+ };
133
+ };
134
+ dismiss: () => {
135
+ model: RemoverModel;
136
+ };
137
+ }>;
138
+ /** The remover's Messages, for the yes and the no of a confirmation. */
139
+ Message: import("foldkit/message").MessageUnion<{
140
+ readonly Confirmed: {};
141
+ readonly Cancelled: {};
142
+ }>;
143
+ at: <Root>(where: {
144
+ readonly data: DomainLike<Root>;
145
+ readonly model: ModelRef<Root, RemoverModel>;
146
+ }) => {
147
+ /** For the placement: a yes becomes the mutation, and its request is remembered. */
148
+ onOut: (confirmed: ConfirmedRemoval) => Step<Root>;
149
+ /** The id being asked about or deleted, for the words of a confirmation. */
150
+ target: (root: Root) => string | null;
151
+ status: (root: Root) => RemoverStatus;
152
+ /** Why the last delete failed, while `status` is `DeleteFailed`. */
153
+ error: (root: Root) => RemoteError | undefined;
154
+ };
155
+ };
156
+ /**
157
+ * A list over one query: which rows to show is the query's, what to show of
158
+ * each is the Selection's. The pages live in Remote; the list holds no state.
159
+ */
160
+ list: <const Name extends string, EntityName extends string, Members, Row, Input>(name: Name, config: {
161
+ readonly query: QueryDescriptor<string, Input, any>;
162
+ readonly selection: Selection<EntityName, Members, Schema.Constraint & {
163
+ readonly Type: Row;
164
+ }>;
165
+ /** How many rows a page holds. Default 25. */
166
+ readonly pageSize?: number;
167
+ /**
168
+ * How a row reads as a choice in a relation picker: its id, and the words
169
+ * that identify it to a person. Give it to a list that feeds pickers.
170
+ */
171
+ readonly choice?: {
172
+ readonly value: (row: Row) => string;
173
+ readonly label: (row: Row) => string;
174
+ };
175
+ }) => {
176
+ name: Name;
177
+ /** The selected members in the Selection's order, each with its label. */
178
+ columns: readonly ListColumn<keyof Members & string>[];
179
+ /**
180
+ * The list where it lives. `input` is the query's input as the Model has
181
+ * it (filters, a search term), or `undefined` while the list is not shown.
182
+ */
183
+ at: <Root>(where: {
184
+ readonly data: DomainLike<Root>;
185
+ readonly input: (root: Root) => Input | undefined;
186
+ }) => {
187
+ /** For `Data.subscriptions`: the page and its rows are fetched and retained while shown. */
188
+ active: {
189
+ name: Name;
190
+ owner: object;
191
+ projectionOf: (root: Root) => Projection<Root, RemoteData<Page<any>>> | undefined;
192
+ };
193
+ page: (root: Root) => RemoteData<Page<Row>>;
194
+ /** The Command that loads the next page onto this one, or `undefined` when there is none. */
195
+ more: (root: Root) => Command<RemoteMessage, never, RemoteClient> | undefined;
196
+ name: Name;
197
+ /** The Entity the rows are of, which is how a picker finds the list for its target. */
198
+ entity: AnyEntity;
199
+ /** Whether the list was given a `choice`, so its rows can be a picker's choices. */
200
+ offersChoices: boolean;
201
+ /**
202
+ * The loaded rows as a picker's choices, read through `choice`. Listing a
203
+ * relation's target is this query, which the application chose and the
204
+ * server authorizes; nothing is read because a relation exists.
205
+ */
206
+ choices: (root: Root) => ReadonlyArray<Choice>;
207
+ };
208
+ };
209
+ /**
210
+ * A form's Submodel view as the view of the editor that wraps it, for
211
+ * `Bundle.withView`: the editor's Messages are the form's, and its Model holds
212
+ * the form's under `form`.
213
+ *
214
+ * ```ts
215
+ * Editor.bundle.pipe(Bundle.withView(Crud.editorView(FormView.submodel(form, view))))
216
+ * ```
217
+ */
218
+ editorView: <FormModel, Message, ViewInputs>(view: Submodel.View<FormModel, Message, ViewInputs>) => Submodel.View<EditorModel<FormModel>, Message, ViewInputs>;
219
+ /**
220
+ * The choices of every relation picker in a form, from the lists of their
221
+ * targets: `foldkit-mixins-form`'s `options`, keyed by the form's keys. A
222
+ * picker whose target no list here is over is a wiring mistake, reported now.
223
+ */
224
+ options: <Key extends string, Root>(form: {
225
+ readonly controls: ReadonlyArray<FormControl<Key>>;
226
+ }, lists: ReadonlyArray<{
227
+ readonly name: string;
228
+ readonly entity: AnyEntity;
229
+ readonly offersChoices: boolean;
230
+ readonly choices: (root: Root) => ReadonlyArray<Choice>;
231
+ }>) => ((root: Root) => { readonly [K in Key]?: ReadonlyArray<Choice>; });
232
+ /**
233
+ * An editor for one form and the mutation its value feeds. The form's value
234
+ * must be the mutation's input, which is what declaring the input once and
235
+ * giving it to both guarantees.
236
+ */
237
+ editor: <const Name extends string, FormName extends string, FormModel, FormMessage extends {
238
+ readonly _tag: string;
239
+ }, Value, E extends AnyEntity, Fields extends Schema.Struct.Fields, Members, R = never>(name: Name, config: {
240
+ readonly form: EditableForm<FormName, FormModel, FormMessage, Value, E, Fields, Members, R>;
241
+ readonly mutation: MutationDescriptor<string, Value, any>;
242
+ }) => {
243
+ bundle: Bundle.Bundle<Name, void, EditorModel<FormModel>, FormMessage, Submitted<Value>, R, never, void, {}, {
244
+ /** Edits `id`: the form is emptied, and filled once the current values arrive. */
245
+ open: (_: EditorModel<FormModel>, id: string) => {
246
+ model: {
247
+ mode: "edit";
248
+ target: string;
249
+ form: FormModel;
250
+ filled: boolean;
251
+ requestId: string | null;
252
+ };
253
+ };
254
+ /** A blank form for a new one: nothing to load. */
255
+ blank: () => {
256
+ model: {
257
+ mode: "new";
258
+ filled: boolean;
259
+ form: FormModel;
260
+ target: string | null;
261
+ requestId: string | null;
262
+ };
263
+ };
264
+ close: () => {
265
+ model: EditorModel<FormModel>;
266
+ };
267
+ }>;
268
+ /** What the editor loads before it shows the form: every member the form writes. */
269
+ selection: Selection<E["name"], import("foldkit-entity").WrittenSpec<Members>, Schema.Struct<{ readonly [K in keyof import("foldkit-entity").WrittenSpec<Members> & keyof E["members"]]: E["members"][K] extends (infer T) ? T extends E["members"][K] ? T extends import("foldkit-entity").EntityField<any, any, infer S extends Schema.Constraint> | import("foldkit-entity").EntityDerived<any, any, infer S extends Schema.Constraint> ? S : T extends import("foldkit-entity").EntityRelation<any, any, infer Target extends AnyEntity, infer C extends import("foldkit-entity").Cardinality, infer Optional extends boolean> ? import("foldkit-entity").WrittenSpec<Members>[K] extends (infer T_1) ? T_1 extends import("foldkit-entity").WrittenSpec<Members>[K] ? T_1 extends import("foldkit-entity").SelectionPage<any, infer S_1 extends Schema.Constraint> ? Schema.Struct<{
270
+ readonly items: Schema.$Array<S_1>;
271
+ readonly hasNext: Schema.Boolean;
272
+ readonly hasPrevious: Schema.Boolean;
273
+ }> : C extends "many" ? Schema.$Array<T_1 extends Selection<any, any, infer S_2 extends Schema.Constraint> ? S_2 : Schema.Struct<{
274
+ readonly entity: Schema.Literal<Target["name"]>;
275
+ readonly id: Schema.String;
276
+ }>> : Optional extends true ? Schema.NullOr<T_1 extends Selection<any, any, infer S_2 extends Schema.Constraint> ? S_2 : Schema.Struct<{
277
+ readonly entity: Schema.Literal<Target["name"]>;
278
+ readonly id: Schema.String;
279
+ }>> : T_1 extends Selection<any, any, infer S_2 extends Schema.Constraint> ? S_2 : Schema.Struct<{
280
+ readonly entity: Schema.Literal<Target["name"]>;
281
+ readonly id: Schema.String;
282
+ }> : never : never : never : never : never; }>>;
283
+ /**
284
+ * The editor where it lives: `model` is its slice of the parent Model (the
285
+ * field it was declared at), and `data` the Remote domain that registers the
286
+ * mutation and the Entity.
287
+ */
288
+ at: <Root>(where: {
289
+ readonly data: DomainLike<Root>;
290
+ readonly model: ModelRef<Root, EditorModel<FormModel>>;
291
+ }) => {
292
+ /** For the placement: a valid submit becomes the mutation, and its request is remembered. */
293
+ onOut: (submitted: Submitted<Value>) => Step<Root>;
294
+ /**
295
+ * For `Data.subscriptions`: while an id is being edited, what it loads is
296
+ * a requirement like any Surface's, so Remote fetches and retains it.
297
+ */
298
+ active: {
299
+ name: Name;
300
+ owner: object;
301
+ projectionOf: (root: Root) => Projection<Root, RemoteData<any>> | undefined;
302
+ };
303
+ sync: Update.Step<Root, never, never>;
304
+ /**
305
+ * Wraps the application's `update` so `sync` runs after every Message:
306
+ * the value may arrive with any of them.
307
+ */
308
+ after: <Message, Result extends {
309
+ readonly model: Root;
310
+ }>(update: (model: Root, message: Message) => Result) => (model: Root, message: Message) => Result;
311
+ /** Why the last save failed, while `status` is `SaveFailed`. */
312
+ saveError: (root: Root) => RemoteError | undefined;
313
+ status: (root: Root) => EditorStatus;
314
+ };
315
+ };
316
+ };
317
+ //#endregion
318
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":"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"}
package/dist/index.mjs ADDED
@@ -0,0 +1,371 @@
1
+ import { Schema } from "effect";
2
+ import { Bundle } from "foldkit-bundle";
3
+ import { Entity } from "foldkit-entity";
4
+ import { Form } from "foldkit-form";
5
+ import { defineMessageUnion } from "foldkit/message";
6
+ import * as Submodel from "foldkit/submodel";
7
+ //#region src/index.ts
8
+ /**
9
+ * `foldkit-crud` — management screens assembled from parts an application
10
+ * already has.
11
+ *
12
+ * An editor joins a `foldkit-form` form, the Remote mutation its value feeds,
13
+ * and the Entity they share: it loads what the form writes, shows it, turns a
14
+ * valid submit into the mutation, and says how that went. Everything it makes is
15
+ * ordinary Foldkit: a Bundle, an ActiveSurface, Update Steps. Nothing is
16
+ * generated from an Entity alone; each capability is declared.
17
+ */
18
+ const columnLabel = (key, member) => {
19
+ const title = member._tag === "Relation" ? void 0 : Schema.resolveAnnotations(member.schema)?.title;
20
+ return typeof title === "string" ? title : Form.labelOf(member) ?? key;
21
+ };
22
+ /** The selected members in the Selection's order, each with its label. */
23
+ const columnsOf = (selection) => {
24
+ const members = selection.entity.members;
25
+ return Object.keys(selection.members).map((key) => ({
26
+ key,
27
+ label: columnLabel(key, members[key]),
28
+ member: members[key]
29
+ }));
30
+ };
31
+ const RemoverMessage = defineMessageUnion({
32
+ Confirmed: {},
33
+ Cancelled: {}
34
+ });
35
+ const idle = {
36
+ target: null,
37
+ requestId: null
38
+ };
39
+ const Crud = {
40
+ /**
41
+ * One Entity read through a Selection, for a page that shows it. Like a list
42
+ * it holds no state: the value is Remote's and the id is the application's.
43
+ */
44
+ detail: (name, config) => ({
45
+ name,
46
+ /** The selected members in the Selection's order, each with its label. */
47
+ fields: columnsOf(config.selection),
48
+ /** The detail where it lives: `id` is the one shown, or `undefined` while none is. */
49
+ at: (where) => {
50
+ const projectionOf = (root) => {
51
+ const id = where.id(root);
52
+ return id === void 0 ? void 0 : where.data.get(config.selection, id);
53
+ };
54
+ return {
55
+ /** For `Data.subscriptions`: the value is fetched and retained while an id is shown. */
56
+ active: {
57
+ name,
58
+ owner: where.data.contract.owner ?? {},
59
+ projectionOf
60
+ },
61
+ value: (root) => projectionOf(root)?.read(root) ?? { _tag: "Initial" }
62
+ };
63
+ }
64
+ }),
65
+ /**
66
+ * Deleting through one mutation, with a yes in between: asked, confirmed,
67
+ * deleted. What the mutation's input is for an id is `input`'s to say.
68
+ */
69
+ remover: (name, config) => {
70
+ return {
71
+ bundle: Bundle.make(name, {
72
+ Model: Schema.Struct({
73
+ target: Schema.NullOr(Schema.String),
74
+ requestId: Schema.NullOr(Schema.String)
75
+ }),
76
+ Message: RemoverMessage,
77
+ init: () => ({ model: idle }),
78
+ update: (model, message) => message._tag === "Cancelled" || model.target === null ? { model: idle } : {
79
+ model,
80
+ outMessage: {
81
+ _tag: "Confirmed",
82
+ id: model.target
83
+ }
84
+ },
85
+ helpers: {
86
+ /** Asks whether to delete `id`. Nothing is deleted until `Confirmed`. */
87
+ ask: (_, id) => ({ model: {
88
+ target: id,
89
+ requestId: null
90
+ } }),
91
+ dismiss: () => ({ model: idle })
92
+ }
93
+ }),
94
+ /** The remover's Messages, for the yes and the no of a confirmation. */
95
+ Message: RemoverMessage,
96
+ at: (where) => {
97
+ const { data, model: slice } = where;
98
+ const outcome = (root) => {
99
+ const { requestId } = slice.get(root);
100
+ return requestId === null ? { _tag: "Unknown" } : data.mutation(root, requestId);
101
+ };
102
+ return {
103
+ /** For the placement: a yes becomes the mutation, and its request is remembered. */
104
+ onOut: (confirmed) => (root) => {
105
+ const started = data.mutate(root, config.mutation, config.input(confirmed.id));
106
+ return {
107
+ model: slice.set(started.model, {
108
+ target: confirmed.id,
109
+ requestId: started.requestId
110
+ }),
111
+ commands: [started.command]
112
+ };
113
+ },
114
+ /** The id being asked about or deleted, for the words of a confirmation. */
115
+ target: (root) => slice.get(root).target,
116
+ status: (root) => {
117
+ const { target } = slice.get(root);
118
+ const deleting = outcome(root);
119
+ return deleting._tag === "Pending" ? "Deleting" : deleting._tag === "Applied" ? "Deleted" : deleting._tag === "Failed" ? "DeleteFailed" : target === null ? "Idle" : "Confirming";
120
+ },
121
+ /** Why the last delete failed, while `status` is `DeleteFailed`. */
122
+ error: (root) => {
123
+ const deleting = outcome(root);
124
+ return deleting._tag === "Failed" ? deleting.error : void 0;
125
+ }
126
+ };
127
+ }
128
+ };
129
+ },
130
+ /**
131
+ * A list over one query: which rows to show is the query's, what to show of
132
+ * each is the Selection's. The pages live in Remote; the list holds no state.
133
+ */
134
+ list: (name, config) => {
135
+ const { query, selection, pageSize = 25, choice } = config;
136
+ return {
137
+ name,
138
+ /** The selected members in the Selection's order, each with its label. */
139
+ columns: columnsOf(selection),
140
+ /**
141
+ * The list where it lives. `input` is the query's input as the Model has
142
+ * it (filters, a search term), or `undefined` while the list is not shown.
143
+ */
144
+ at: (where) => {
145
+ const { data, input } = where;
146
+ const projectionOf = (root) => {
147
+ const value = input(root);
148
+ return value === void 0 ? void 0 : data.query(query, value, {
149
+ select: selection,
150
+ first: pageSize
151
+ });
152
+ };
153
+ const page = (root) => projectionOf(root)?.read(root) ?? { _tag: "Initial" };
154
+ return {
155
+ /** For `Data.subscriptions`: the page and its rows are fetched and retained while shown. */
156
+ active: {
157
+ name,
158
+ owner: data.contract.owner ?? {},
159
+ projectionOf
160
+ },
161
+ page,
162
+ /** The Command that loads the next page onto this one, or `undefined` when there is none. */
163
+ more: (root) => {
164
+ const projection = projectionOf(root);
165
+ const next = projection === void 0 ? void 0 : data.next(root, projection);
166
+ return next === void 0 ? void 0 : data.fetch(next);
167
+ },
168
+ name,
169
+ /** The Entity the rows are of, which is how a picker finds the list for its target. */
170
+ entity: selection.entity,
171
+ /** Whether the list was given a `choice`, so its rows can be a picker's choices. */
172
+ offersChoices: choice !== void 0,
173
+ /**
174
+ * The loaded rows as a picker's choices, read through `choice`. Listing a
175
+ * relation's target is this query, which the application chose and the
176
+ * server authorizes; nothing is read because a relation exists.
177
+ */
178
+ choices: (root) => {
179
+ if (choice === void 0) throw new Error(`Crud list "${name}": give it a "choice" to use its rows in a picker`);
180
+ const read = page(root);
181
+ return read._tag === "Ready" || read._tag === "Refreshing" ? read.value.items.map((row) => ({
182
+ value: choice.value(row),
183
+ label: choice.label(row)
184
+ })) : [];
185
+ }
186
+ };
187
+ }
188
+ };
189
+ },
190
+ /**
191
+ * A form's Submodel view as the view of the editor that wraps it, for
192
+ * `Bundle.withView`: the editor's Messages are the form's, and its Model holds
193
+ * the form's under `form`.
194
+ *
195
+ * ```ts
196
+ * Editor.bundle.pipe(Bundle.withView(Crud.editorView(FormView.submodel(form, view))))
197
+ * ```
198
+ */
199
+ editorView: (view) => Submodel.defineView(((model, inputs, h) => view(model.form, inputs, h))),
200
+ /**
201
+ * The choices of every relation picker in a form, from the lists of their
202
+ * targets: `foldkit-mixins-form`'s `options`, keyed by the form's keys. A
203
+ * picker whose target no list here is over is a wiring mistake, reported now.
204
+ */
205
+ options: (form, lists) => {
206
+ const pickers = form.controls.flatMap(({ key, control }) => {
207
+ if (control._tag !== "RelationOne" && control._tag !== "RelationMany") return [];
208
+ const list = lists.find((candidate) => Entity.same(candidate.entity, control.target));
209
+ if (list === void 0) throw new Error(`Crud.options: "${key}" picks a ${control.target.name}, and no list given is over ${control.target.name}`);
210
+ if (!list.offersChoices) throw new Error(`Crud.options: "${key}" would pick from list "${list.name}", which has no "choice"`);
211
+ return [[key, list]];
212
+ });
213
+ return (root) => Object.fromEntries(pickers.map(([key, list]) => [key, list.choices(root)]));
214
+ },
215
+ /**
216
+ * An editor for one form and the mutation its value feeds. The form's value
217
+ * must be the mutation's input, which is what declaring the input once and
218
+ * giving it to both guarantees.
219
+ */
220
+ editor: (name, config) => {
221
+ const { form, mutation } = config;
222
+ const closed = {
223
+ form: form.initial,
224
+ mode: "closed",
225
+ target: null,
226
+ filled: false,
227
+ requestId: null
228
+ };
229
+ const Model = Schema.Struct({
230
+ form: form.bundle.Model,
231
+ mode: Schema.Literals([
232
+ "closed",
233
+ "new",
234
+ "edit"
235
+ ]),
236
+ target: Schema.NullOr(Schema.String),
237
+ filled: Schema.Boolean,
238
+ requestId: Schema.NullOr(Schema.String)
239
+ });
240
+ const bundle = Bundle.make(name, {
241
+ Model,
242
+ Message: form.bundle.Message,
243
+ init: () => ({ model: closed }),
244
+ update: (model, message) => {
245
+ const next = form.bundle.update(model.form, message, void 0);
246
+ const requestId = message._tag === "Changed" ? null : model.requestId;
247
+ const edited = {
248
+ model: {
249
+ ...model,
250
+ form: next.model,
251
+ requestId
252
+ },
253
+ ...next.commands === void 0 ? {} : { commands: next.commands }
254
+ };
255
+ return next.outMessage === void 0 ? edited : {
256
+ ...edited,
257
+ outMessage: next.outMessage
258
+ };
259
+ },
260
+ helpers: {
261
+ /** Edits `id`: the form is emptied, and filled once the current values arrive. */
262
+ open: (_, id) => ({ model: {
263
+ ...closed,
264
+ mode: "edit",
265
+ target: id
266
+ } }),
267
+ /** A blank form for a new one: nothing to load. */
268
+ blank: () => ({ model: {
269
+ ...closed,
270
+ mode: "new",
271
+ filled: true
272
+ } }),
273
+ close: () => ({ model: closed })
274
+ }
275
+ });
276
+ const current = Entity.selectFor(form.input);
277
+ return {
278
+ bundle,
279
+ /** What the editor loads before it shows the form: every member the form writes. */
280
+ selection: current,
281
+ /**
282
+ * The editor where it lives: `model` is its slice of the parent Model (the
283
+ * field it was declared at), and `data` the Remote domain that registers the
284
+ * mutation and the Entity.
285
+ */
286
+ at: (where) => {
287
+ const { data, model: slice } = where;
288
+ const loaded = (root) => {
289
+ const { mode, target } = slice.get(root);
290
+ return mode === "edit" && target !== null ? data.get(current, target).read(root) : void 0;
291
+ };
292
+ /** Shows the loaded value once, the first time it is there. Later refreshes leave the drafts alone. */
293
+ const sync = (root) => {
294
+ const editor = slice.get(root);
295
+ const read = loaded(root);
296
+ if (editor.filled || read === void 0) return { model: root };
297
+ if (read._tag !== "Ready" && read._tag !== "Refreshing") return { model: root };
298
+ const values = Entity.valuesFor(form.input, read.value);
299
+ return { model: slice.set(root, {
300
+ ...editor,
301
+ form: form.fill(editor.form, values).model,
302
+ filled: true
303
+ }) };
304
+ };
305
+ const saveOf = (root) => {
306
+ const { requestId } = slice.get(root);
307
+ return requestId === null ? { _tag: "Unknown" } : data.mutation(root, requestId);
308
+ };
309
+ return {
310
+ /** For the placement: a valid submit becomes the mutation, and its request is remembered. */
311
+ onOut: (submitted) => (root) => {
312
+ const started = data.mutate(root, mutation, submitted.value);
313
+ const editor = slice.get(started.model);
314
+ return {
315
+ model: slice.set(started.model, {
316
+ ...editor,
317
+ requestId: started.requestId
318
+ }),
319
+ commands: [started.command]
320
+ };
321
+ },
322
+ /**
323
+ * For `Data.subscriptions`: while an id is being edited, what it loads is
324
+ * a requirement like any Surface's, so Remote fetches and retains it.
325
+ */
326
+ active: {
327
+ name,
328
+ owner: data.contract.owner ?? {},
329
+ projectionOf: (root) => {
330
+ const { mode, target } = slice.get(root);
331
+ return mode === "edit" && target !== null ? data.get(current, target) : void 0;
332
+ }
333
+ },
334
+ sync,
335
+ /**
336
+ * Wraps the application's `update` so `sync` runs after every Message:
337
+ * the value may arrive with any of them.
338
+ */
339
+ after: (update) => (model, message) => {
340
+ const next = update(model, message);
341
+ return {
342
+ ...next,
343
+ model: sync(next.model).model
344
+ };
345
+ },
346
+ /** Why the last save failed, while `status` is `SaveFailed`. */
347
+ saveError: (root) => {
348
+ const save = saveOf(root);
349
+ return save._tag === "Failed" ? save.error : void 0;
350
+ },
351
+ status: (root) => {
352
+ const editor = slice.get(root);
353
+ if (editor.mode === "closed") return "Closed";
354
+ const read = loaded(root);
355
+ if (read?._tag === "NotFound") return "NotFound";
356
+ const save = saveOf(root);
357
+ if (save._tag === "Pending") return "Saving";
358
+ if (save._tag === "Failed") return "SaveFailed";
359
+ if (save._tag === "Applied") return "Saved";
360
+ if (editor.filled) return "Editing";
361
+ return read?._tag === "Failed" ? "LoadFailed" : "Loading";
362
+ }
363
+ };
364
+ }
365
+ };
366
+ }
367
+ };
368
+ //#endregion
369
+ export { Crud };
370
+
371
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["/**\n * `foldkit-crud` — management screens assembled from parts an application\n * already has.\n *\n * An editor joins a `foldkit-form` form, the Remote mutation its value feeds,\n * and the Entity they share: it loads what the form writes, shows it, turns a\n * valid submit into the mutation, and says how that went. Everything it makes is\n * ordinary Foldkit: a Bundle, an ActiveSurface, Update Steps. Nothing is\n * generated from an Entity alone; each capability is declared.\n */\nimport { Schema } from 'effect'\nimport { Bundle } from 'foldkit-bundle'\nimport {\n Entity,\n type AnyEntity,\n type EntityInput,\n type EntityMember,\n type Selection,\n} from 'foldkit-entity'\nimport { Form, type FormControl, type Submitted } from 'foldkit-form'\nimport type {\n MutationDescriptor,\n Page,\n QueryDescriptor,\n MutationStatus,\n RemoteClient,\n RemoteData,\n RemoteError,\n RemoteMessage,\n} from 'foldkit-remote'\nimport type { ActiveSurface, ModelRef, Projection } from 'foldkit-surface'\nimport type { Command } from 'foldkit/command'\nimport { defineMessageUnion } from 'foldkit/message'\nimport type { Html, HtmlBuilder } from 'foldkit/html'\nimport * as Submodel from 'foldkit/submodel'\nimport type * as Update from 'foldkit/update'\n\n/** The parts of a `Form.make` result an editor wraps. */\nexport interface EditableForm<\n Name extends string,\n FormModel,\n FormMessage,\n Value,\n E extends AnyEntity,\n Fields extends Schema.Struct.Fields,\n Members,\n R = never,\n> {\n readonly bundle: {\n readonly name: Name\n readonly Model: Schema.Codec<FormModel, unknown>\n readonly Message: Schema.Codec<FormMessage, unknown>\n readonly update: (\n model: FormModel,\n message: FormMessage,\n args: void,\n ) => Update.ReturnWithOutMessage<FormModel, FormMessage, Submitted<Value>, R>\n }\n readonly input: EntityInput<E, Fields, Members>\n readonly initial: FormModel\n readonly fill: (model: FormModel, values: Partial<Value>) => { readonly model: FormModel }\n}\n\n/**\n * An editor's slice of the parent Model. `target` is the id being edited, or\n * `null` for a new one; `filled` is whether the loaded value has been shown;\n * `requestId` is the save in progress or last settled.\n */\nexport interface EditorModel<FormModel> {\n readonly form: FormModel\n readonly mode: 'closed' | 'new' | 'edit'\n readonly target: string | null\n readonly filled: boolean\n readonly requestId: string | null\n}\n\n/**\n * A remover's slice of the parent Model: the id it was asked to delete and is\n * waiting on a yes for, and the delete in progress or last settled.\n */\nexport interface RemoverModel {\n readonly target: string | null\n readonly requestId: string | null\n}\n\n/** A remover's out Message: the user said yes to deleting `id`. */\nexport interface ConfirmedRemoval {\n readonly _tag: 'Confirmed'\n readonly id: string\n}\n\nexport type RemoverStatus =\n | 'Idle'\n /** Asked to delete an id, and waiting for a yes or a no. */\n | 'Confirming'\n | 'Deleting'\n | 'Deleted'\n | 'DeleteFailed'\n\nexport type EditorStatus =\n | 'Closed'\n /** Editing an id whose current values have not arrived. */\n | 'Loading'\n | 'NotFound'\n | 'LoadFailed'\n | 'Editing'\n | 'Saving'\n | 'Saved'\n | 'SaveFailed'\n\n/** The parts of a bound Remote domain an editor uses. */\nexport interface DomainLike<Root> {\n // Method syntax: the descriptors are checked where the editor is made, not here.\n mutate(\n model: Root,\n mutation: any,\n input: any,\n ): {\n readonly model: Root\n readonly requestId: string\n readonly command: Command<RemoteMessage, never, RemoteClient>\n }\n get(selection: any, id: string): Projection<Root, RemoteData<any>>\n query(query: any, input: any, options: any): Projection<Root, RemoteData<Page<any>>>\n next(model: Root, projection: any): { readonly query: string } | undefined\n fetch(ref: any): Command<RemoteMessage, never, RemoteClient>\n mutation(model: Root, requestId: string): MutationStatus\n readonly contract: { readonly owner?: object | undefined }\n}\n\ntype Step<Root> = Update.Step<Root, RemoteMessage, RemoteClient>\n\n/** One thing a relation picker offers. */\nexport interface Choice {\n readonly value: string\n readonly label: string\n}\n\n/** One column of a list, or one line of a detail: a member the Selection reads. */\nexport interface ListColumn<Key extends string = string> {\n readonly key: Key\n /** The schema's `title` annotation, else `Form.label` metadata, else the key. */\n readonly label: string\n readonly member: EntityMember\n}\n\nconst columnLabel = (key: string, member: EntityMember): string => {\n const title =\n member._tag === 'Relation'\n ? undefined\n : Schema.resolveAnnotations(member.schema as Schema.Top)?.title\n return typeof title === 'string' ? title : (Form.labelOf(member) ?? key)\n}\n\n/** The selected members in the Selection's order, each with its label. */\nconst columnsOf = <Members>(\n selection: Selection<string, Members, Schema.Constraint>,\n): ReadonlyArray<ListColumn<keyof Members & string>> => {\n const members: Readonly<Record<string, EntityMember>> = selection.entity.members\n return Object.keys(selection.members as object).map(key => ({\n key: key as keyof Members & string,\n label: columnLabel(key, members[key]!),\n member: members[key]!,\n }))\n}\n\nconst RemoverMessage = defineMessageUnion({ Confirmed: {}, Cancelled: {} })\ntype RemoverMessage = typeof RemoverMessage.Type\n\nconst idle: RemoverModel = { target: null, requestId: null }\n\nexport const Crud = {\n /**\n * One Entity read through a Selection, for a page that shows it. Like a list\n * it holds no state: the value is Remote's and the id is the application's.\n */\n detail: <const Name extends string, EntityName extends string, Members, Row>(\n name: Name,\n config: {\n readonly selection: Selection<EntityName, Members, Schema.Constraint & { readonly Type: Row }>\n },\n ) => ({\n name,\n /** The selected members in the Selection's order, each with its label. */\n fields: columnsOf(config.selection),\n\n /** The detail where it lives: `id` is the one shown, or `undefined` while none is. */\n at: <Root>(where: {\n readonly data: DomainLike<Root>\n readonly id: (root: Root) => string | undefined\n }) => {\n const projectionOf = (root: Root) => {\n const id = where.id(root)\n return id === undefined ? undefined : where.data.get(config.selection, id)\n }\n return {\n /** For `Data.subscriptions`: the value is fetched and retained while an id is shown. */\n active: {\n name,\n owner: where.data.contract.owner ?? {},\n projectionOf,\n } satisfies ActiveSurface<Root>,\n value: (root: Root): RemoteData<Row> =>\n projectionOf(root)?.read(root) ?? { _tag: 'Initial' },\n }\n },\n }),\n\n /**\n * Deleting through one mutation, with a yes in between: asked, confirmed,\n * deleted. What the mutation's input is for an id is `input`'s to say.\n */\n remover: <const Name extends string, Input>(\n name: Name,\n config: {\n readonly mutation: MutationDescriptor<string, Input, any>\n readonly input: (id: string) => Input\n },\n ) => {\n const bundle = Bundle.make(name, {\n Model: Schema.Struct({\n target: Schema.NullOr(Schema.String),\n requestId: Schema.NullOr(Schema.String),\n }) as unknown as Schema.Codec<RemoverModel, unknown>,\n Message: RemoverMessage,\n init: () => ({ model: idle }),\n update: (model: RemoverModel, message: RemoverMessage) =>\n message._tag === 'Cancelled' || model.target === null\n ? { model: idle }\n : { model, outMessage: { _tag: 'Confirmed', id: model.target } as ConfirmedRemoval },\n helpers: {\n /** Asks whether to delete `id`. Nothing is deleted until `Confirmed`. */\n ask: (_: RemoverModel, id: string) => ({ model: { target: id, requestId: null } }),\n dismiss: () => ({ model: idle }),\n },\n })\n\n return {\n bundle,\n /** The remover's Messages, for the yes and the no of a confirmation. */\n Message: RemoverMessage,\n\n at: <Root>(where: {\n readonly data: DomainLike<Root>\n readonly model: ModelRef<Root, RemoverModel>\n }) => {\n const { data, model: slice } = where\n const outcome = (root: Root): MutationStatus => {\n const { requestId } = slice.get(root)\n return requestId === null ? { _tag: 'Unknown' } : data.mutation(root, requestId)\n }\n return {\n /** For the placement: a yes becomes the mutation, and its request is remembered. */\n onOut:\n (confirmed: ConfirmedRemoval): Step<Root> =>\n root => {\n const started = data.mutate(root, config.mutation, config.input(confirmed.id))\n return {\n model: slice.set(started.model, {\n target: confirmed.id,\n requestId: started.requestId,\n }),\n commands: [started.command],\n }\n },\n\n /** The id being asked about or deleted, for the words of a confirmation. */\n target: (root: Root): string | null => slice.get(root).target,\n\n status: (root: Root): RemoverStatus => {\n const { target } = slice.get(root)\n const deleting = outcome(root)\n return deleting._tag === 'Pending'\n ? 'Deleting'\n : deleting._tag === 'Applied'\n ? 'Deleted'\n : deleting._tag === 'Failed'\n ? 'DeleteFailed'\n : target === null\n ? 'Idle'\n : 'Confirming'\n },\n\n /** Why the last delete failed, while `status` is `DeleteFailed`. */\n error: (root: Root): RemoteError | undefined => {\n const deleting = outcome(root)\n return deleting._tag === 'Failed' ? deleting.error : undefined\n },\n }\n },\n }\n },\n\n /**\n * A list over one query: which rows to show is the query's, what to show of\n * each is the Selection's. The pages live in Remote; the list holds no state.\n */\n list: <const Name extends string, EntityName extends string, Members, Row, Input>(\n name: Name,\n config: {\n readonly query: QueryDescriptor<string, Input, any>\n readonly selection: Selection<EntityName, Members, Schema.Constraint & { readonly Type: Row }>\n /** How many rows a page holds. Default 25. */\n readonly pageSize?: number\n /**\n * How a row reads as a choice in a relation picker: its id, and the words\n * that identify it to a person. Give it to a list that feeds pickers.\n */\n readonly choice?: {\n readonly value: (row: Row) => string\n readonly label: (row: Row) => string\n }\n },\n ) => {\n const { query, selection, pageSize = 25, choice } = config\n const columns = columnsOf(selection)\n\n return {\n name,\n /** The selected members in the Selection's order, each with its label. */\n columns,\n\n /**\n * The list where it lives. `input` is the query's input as the Model has\n * it (filters, a search term), or `undefined` while the list is not shown.\n */\n at: <Root>(where: {\n readonly data: DomainLike<Root>\n readonly input: (root: Root) => Input | undefined\n }) => {\n const { data, input } = where\n const projectionOf = (root: Root) => {\n const value = input(root)\n return value === undefined\n ? undefined\n : data.query(query, value, { select: selection, first: pageSize })\n }\n const page = (root: Root): RemoteData<Page<Row>> =>\n projectionOf(root)?.read(root) ?? { _tag: 'Initial' }\n\n return {\n /** For `Data.subscriptions`: the page and its rows are fetched and retained while shown. */\n active: {\n name,\n owner: data.contract.owner ?? {},\n projectionOf,\n } satisfies ActiveSurface<Root>,\n\n page,\n\n /** The Command that loads the next page onto this one, or `undefined` when there is none. */\n more: (root: Root): Command<RemoteMessage, never, RemoteClient> | undefined => {\n const projection = projectionOf(root)\n const next = projection === undefined ? undefined : data.next(root, projection)\n return next === undefined ? undefined : data.fetch(next)\n },\n\n name,\n /** The Entity the rows are of, which is how a picker finds the list for its target. */\n entity: selection.entity as AnyEntity,\n /** Whether the list was given a `choice`, so its rows can be a picker's choices. */\n offersChoices: choice !== undefined,\n\n /**\n * The loaded rows as a picker's choices, read through `choice`. Listing a\n * relation's target is this query, which the application chose and the\n * server authorizes; nothing is read because a relation exists.\n */\n choices: (root: Root): ReadonlyArray<Choice> => {\n if (choice === undefined)\n throw new Error(`Crud list \"${name}\": give it a \"choice\" to use its rows in a picker`)\n const read = page(root)\n return read._tag === 'Ready' || read._tag === 'Refreshing'\n ? read.value.items.map(row => ({\n value: choice.value(row),\n label: choice.label(row),\n }))\n : []\n },\n }\n },\n }\n },\n\n /**\n * A form's Submodel view as the view of the editor that wraps it, for\n * `Bundle.withView`: the editor's Messages are the form's, and its Model holds\n * the form's under `form`.\n *\n * ```ts\n * Editor.bundle.pipe(Bundle.withView(Crud.editorView(FormView.submodel(form, view))))\n * ```\n */\n editorView: <FormModel, Message, ViewInputs>(\n view: Submodel.View<FormModel, Message, ViewInputs>,\n ): Submodel.View<EditorModel<FormModel>, Message, ViewInputs> =>\n Submodel.defineView<EditorModel<FormModel>, Message, ViewInputs>(((\n model: EditorModel<FormModel>,\n inputs: ViewInputs,\n h: HtmlBuilder<Message>,\n ): Html =>\n (view as (model: FormModel, inputs: ViewInputs, h: HtmlBuilder<Message>) => Html)(\n model.form,\n inputs,\n h,\n )) as never),\n\n /**\n * The choices of every relation picker in a form, from the lists of their\n * targets: `foldkit-mixins-form`'s `options`, keyed by the form's keys. A\n * picker whose target no list here is over is a wiring mistake, reported now.\n */\n options: <Key extends string, Root>(\n form: { readonly controls: ReadonlyArray<FormControl<Key>> },\n lists: ReadonlyArray<{\n readonly name: string\n readonly entity: AnyEntity\n readonly offersChoices: boolean\n readonly choices: (root: Root) => ReadonlyArray<Choice>\n }>,\n ): ((root: Root) => { readonly [K in Key]?: ReadonlyArray<Choice> }) => {\n const pickers = form.controls.flatMap(({ key, control }) => {\n if (control._tag !== 'RelationOne' && control._tag !== 'RelationMany') return []\n const list = lists.find(candidate => Entity.same(candidate.entity, control.target))\n if (list === undefined)\n throw new Error(\n `Crud.options: \"${key}\" picks a ${control.target.name}, and no list given is over ${control.target.name}`,\n )\n if (!list.offersChoices)\n throw new Error(\n `Crud.options: \"${key}\" would pick from list \"${list.name}\", which has no \"choice\"`,\n )\n return [[key, list] as const]\n })\n return root =>\n Object.fromEntries(pickers.map(([key, list]) => [key, list.choices(root)])) as {\n readonly [K in Key]?: ReadonlyArray<Choice>\n }\n },\n\n /**\n * An editor for one form and the mutation its value feeds. The form's value\n * must be the mutation's input, which is what declaring the input once and\n * giving it to both guarantees.\n */\n editor: <\n const Name extends string,\n FormName extends string,\n FormModel,\n FormMessage extends { readonly _tag: string },\n Value,\n E extends AnyEntity,\n Fields extends Schema.Struct.Fields,\n Members,\n R = never,\n >(\n name: Name,\n config: {\n readonly form: EditableForm<FormName, FormModel, FormMessage, Value, E, Fields, Members, R>\n readonly mutation: MutationDescriptor<string, Value, any>\n },\n ) => {\n type Model = EditorModel<FormModel>\n const { form, mutation } = config\n\n const closed: Model = {\n form: form.initial,\n mode: 'closed',\n target: null,\n filled: false,\n requestId: null,\n }\n const Model = Schema.Struct({\n form: form.bundle.Model,\n mode: Schema.Literals(['closed', 'new', 'edit']),\n target: Schema.NullOr(Schema.String),\n filled: Schema.Boolean,\n requestId: Schema.NullOr(Schema.String),\n }) as unknown as Schema.Codec<Model, unknown>\n\n // The editor's Messages are the form's own: it adds state around the form,\n // not a second vocabulary.\n const bundle = Bundle.make(name, {\n Model,\n Message: form.bundle.Message,\n init: () => ({ model: closed }),\n update: (model: Model, message: FormMessage) => {\n const next = form.bundle.update(model.form, message, undefined)\n // An edit after a save starts a new round; the last save no longer describes the form.\n const requestId = message._tag === 'Changed' ? null : model.requestId\n // The form's Commands are the editor's: a check the form started has to run.\n const edited = {\n model: { ...model, form: next.model, requestId },\n ...(next.commands === undefined ? {} : { commands: next.commands }),\n }\n return next.outMessage === undefined ? edited : { ...edited, outMessage: next.outMessage }\n },\n helpers: {\n /** Edits `id`: the form is emptied, and filled once the current values arrive. */\n open: (_: Model, id: string) => ({\n model: { ...closed, mode: 'edit' as const, target: id },\n }),\n /** A blank form for a new one: nothing to load. */\n blank: () => ({ model: { ...closed, mode: 'new' as const, filled: true } }),\n close: () => ({ model: closed }),\n },\n })\n\n const current = Entity.selectFor(form.input)\n\n return {\n bundle,\n /** What the editor loads before it shows the form: every member the form writes. */\n selection: current,\n\n /**\n * The editor where it lives: `model` is its slice of the parent Model (the\n * field it was declared at), and `data` the Remote domain that registers the\n * mutation and the Entity.\n */\n at: <Root>(where: {\n readonly data: DomainLike<Root>\n readonly model: ModelRef<Root, Model>\n }) => {\n const { data, model: slice } = where\n const loaded = (root: Root): RemoteData<any> | undefined => {\n const { mode, target } = slice.get(root)\n return mode === 'edit' && target !== null\n ? data.get(current, target).read(root)\n : undefined\n }\n\n /** Shows the loaded value once, the first time it is there. Later refreshes leave the drafts alone. */\n const sync: Update.Step<Root, never, never> = root => {\n const editor = slice.get(root)\n const read = loaded(root)\n if (editor.filled || read === undefined) return { model: root }\n if (read._tag !== 'Ready' && read._tag !== 'Refreshing') return { model: root }\n const values = Entity.valuesFor(form.input, read.value) as Partial<Value>\n return {\n model: slice.set(root, {\n ...editor,\n form: form.fill(editor.form, values).model,\n filled: true,\n }),\n }\n }\n\n const saveOf = (root: Root): MutationStatus => {\n const { requestId } = slice.get(root)\n return requestId === null ? { _tag: 'Unknown' } : data.mutation(root, requestId)\n }\n\n return {\n /** For the placement: a valid submit becomes the mutation, and its request is remembered. */\n onOut:\n (submitted: Submitted<Value>): Step<Root> =>\n root => {\n const started = data.mutate(root, mutation, submitted.value)\n const editor = slice.get(started.model)\n return {\n model: slice.set(started.model, { ...editor, requestId: started.requestId }),\n commands: [started.command],\n }\n },\n\n /**\n * For `Data.subscriptions`: while an id is being edited, what it loads is\n * a requirement like any Surface's, so Remote fetches and retains it.\n */\n active: {\n name,\n owner: data.contract.owner ?? {},\n projectionOf: root => {\n const { mode, target } = slice.get(root)\n return mode === 'edit' && target !== null ? data.get(current, target) : undefined\n },\n } satisfies ActiveSurface<Root>,\n\n sync,\n\n /**\n * Wraps the application's `update` so `sync` runs after every Message:\n * the value may arrive with any of them.\n */\n after:\n <Message, Result extends { readonly model: Root }>(\n update: (model: Root, message: Message) => Result,\n ) =>\n (model: Root, message: Message): Result => {\n const next = update(model, message)\n return { ...next, model: sync(next.model).model }\n },\n\n /** Why the last save failed, while `status` is `SaveFailed`. */\n saveError: (root: Root): RemoteError | undefined => {\n const save = saveOf(root)\n return save._tag === 'Failed' ? save.error : undefined\n },\n\n status: (root: Root): EditorStatus => {\n const editor = slice.get(root)\n if (editor.mode === 'closed') return 'Closed'\n // Gone outranks everything: a save that landed describes a thing that no longer exists.\n const read = loaded(root)\n if (read?._tag === 'NotFound') return 'NotFound'\n const save = saveOf(root)\n if (save._tag === 'Pending') return 'Saving'\n if (save._tag === 'Failed') return 'SaveFailed'\n if (save._tag === 'Applied') return 'Saved'\n if (editor.filled) return 'Editing'\n return read?._tag === 'Failed' ? 'LoadFailed' : 'Loading'\n },\n }\n },\n }\n },\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "foldkit-crud",
3
+ "version": "0.1.0",
4
+ "description": "Create, read, update and delete screens for Foldkit Plus, assembled from a form, a Remote operation, and the Entity they share. Each capability is declared, never implied.",
5
+ "keywords": [
6
+ "foldkit",
7
+ "effect",
8
+ "crud",
9
+ "admin",
10
+ "form",
11
+ "remote",
12
+ "typescript"
13
+ ],
14
+ "type": "module",
15
+ "license": "MIT",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "author": "Patrick Glenn <doeixd@gmail.com>",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/doeixd/foldkit-plus.git",
23
+ "directory": "packages/crud"
24
+ },
25
+ "homepage": "https://github.com/doeixd/foldkit-plus/tree/main/packages/crud#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/doeixd/foldkit-plus/issues"
28
+ },
29
+ "sideEffects": false,
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/index.d.mts",
33
+ "import": "./dist/index.mjs"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "dependencies": {
42
+ "foldkit-bundle": "0.2.0",
43
+ "foldkit-entity": "0.1.0",
44
+ "foldkit-form": "0.1.0",
45
+ "foldkit-remote": "0.4.0",
46
+ "foldkit-surface": "0.3.0"
47
+ },
48
+ "peerDependencies": {
49
+ "effect": "^4.0.0-rc.112",
50
+ "foldkit": "^0.158.2"
51
+ },
52
+ "devDependencies": {
53
+ "effect": "4.0.0-rc.112",
54
+ "foldkit": "0.158.2",
55
+ "tsdown": "^0.23.0",
56
+ "typescript": "5.9.3"
57
+ },
58
+ "scripts": {
59
+ "build": "tsdown",
60
+ "typecheck": "tsc -b"
61
+ }
62
+ }