CRUD / foldkit-crud
An editor
An editor joins a form, the mutation its value feeds, and the Entity they
share. EditPostForm is a Form.make result and EditPostMutation a Remote
mutation over the same input struct, as in
examples/entity; Blog is that application's Entity
domain.
import { Crud } from 'foldkit-crud'
import { Bundle } from 'foldkit-bundle'
import { Remote, type RemoteClient } from 'foldkit-remote'
import { Surface } from 'foldkit-surface'
const Editor = Crud.editor('PostEditor', { form: EditPostForm, mutation: EditPostMutation })
// The editor is a Submodel of the page: a Model field and a Message variant.
const Base = Bundle.compose({ remote: Remote.Model }).pipe(
Bundle.withMessages(Remote.messages),
Bundle.withChild('editor', Editor.bundle),
)
const App = Surface.application(Base)
const Data = Remote.make({
model: App.model.remote,
entities: Object.values(Blog),
mutations: [EditPostMutation],
})
// Where it lives: its slice of the Model, and the domain it saves through.
const PostEditor = Editor.at({ data: Data, model: App.model.editor })
// Its onOut needs the domain, so it is given once the domain exists.
const Page = Base.pipe(
Bundle.withServices<RemoteClient>(),
Bundle.configure('editor', { onOut: PostEditor.onOut }),
)
const Placed = Page.children.editor
const { placements } = Page
const update = PostEditor.after(
placements.update((model, message) =>
Remote.reduces(message) ? { model: Data.reduce(model, message) } : { model },
),
)
const subscriptions = Data.subscriptions({ editor: PostEditor.active })Crud.editorchecks, in the types, that the form's value is the mutation's input. Declaring the input struct once and giving it to both is what makes that true.Editor.bundlewraps the form's Bundle. Its Messages are the form's own, so a view dispatchesEditPostForm.Message.Changed(...)exactly as before.Bundle.composestates the page once, with the editor undereditor. The editor'sonOutis made from the Remote domain, which is made from the page's own Model, so it is given afterwards withBundle.configure; until then the page'splacementsare a type error that names the editor. See composing a parent.Editor.attakes the editor's slice as aModelRef(App.model.editor) and the bound Remote domain, and returns what needs the parent:onOutfor the placement: a valid submit becomesData.mutate, and the editor remembers the request id.active: while an id is open, what the form writes is fetched and retained.after(update): wrapsupdateso the editor fills the form and reconciles its status after every Message.syncis that Step on its own, for anupdatecomposed another way.status(model),saveError(model)while it isSaveFailed,refresh(model)to ask for the value again while it isLoadFailed, andtarget(model), the id being edited (nullfor a new one, or when closed).
- The save is a Command that needs
RemoteClient, so the page names it withBundle.withServices<RemoteClient>().
What starts the work
| Call | What happens |
|---|---|
Crud.editor(...) / Editor.at(...) | Builds the editor and its integration; no request runs |
Placed.helpers.open(id) | Returns an Update Step; apply it in your update to open a record |
Data.subscriptions({ editor: PostEditor.active }) | Describes the loader; the runtime installs it and provides RemoteClient |
PostEditor.after(update) | Fills the form once the value loads, and reconciles the status after each Message |
A valid submit, through onOut | Returns the mutation Command; its result comes back through Remote |
Include the placement's own Subscriptions as well if your form has any. Do not
read Submitted as Saved: only the mutation's outcome settles the save.
Opening it
open, blank and close are helpers of the placement, so they are Update
Steps to use from your own update:
Placed.helpers.open('p2') // edit p2: the form empties, then fills when the value arrives
Placed.helpers.blank() // a new one: nothing to load, editing at once
Placed.helpers.close()One editor serves one form. Create and edit share one when the input's id is
an Option: a blank form saves with Option.none(), an opened one with the id
it was filled with, and the server branches on which (foldkit-guard's
GuardServer.mutation checks a none against Create). Once a create is
saved, its form starts blank again: its values are the new row's now, and
saving the form again would be another create. A form the author went on
typing in while it saved is kept, as the next one's draft.
const SaveProjectInput = Entity.input(
Project,
Schema.Struct({ id: Schema.OptionFromNullOr(Schema.String), name: Schema.String }),
)
const SaveProject = Mutation.make('SaveProject', {
Input: SaveProjectInput.schema,
Output: { id: Schema.String },
})
const SaveProjectForm = Form.make('SaveProject', SaveProjectInput, {
inputs: { id: Input.hidden() },
})
const Editor = Crud.editor('ProjectEditor', { form: SaveProjectForm, mutation: SaveProject })A declared write is one command (Write.update or Write.insert), so over
those the create form and the edit form are two editors, and blank is for
the one whose form creates.
Its status
| Status | When |
|---|---|
Closed | nothing is open |
Loading | an id is open and its current values have not arrived |
Editing | the form is showing, with no save in progress or just settled |
Saving | this editor's mutation is pending |
Saved | it was applied |
SaveFailed | it failed; the drafts are kept, and saveError(model) says why |
Conflict | the server refused it with Refusal.conflict: the row moved on since it was read |
Moved | someone else saved the row since it was read (a declared write that expects a revision); the draft is kept, and a save of it would be a conflict |
LoadFailed | reading it failed, with no earlier value to show; refresh(model) asks again |
NotFound | it is gone: the server answered without it, a mutation deleted it, or a live event did. This outranks a save that landed |
An edit after a save returns to Editing: the last save no longer describes
what is in the form.
A save the server refuses with Refusal.field(key, reason) for one of the
form's keys is shown on that key, as the form's own Refused would show it: the
key reads invalid with the reason, as text, keeping what was typed, until it is
edited. It is shown once per save, and the status is SaveFailed. A refusal of
a key the form does not have is left to the status and saveError.
How it treats the form
- The form is filled once. When the value is read again, by a refresh or a live update, the drafts are left alone, so nothing the user typed is overwritten. Opening the same or another id empties the form and fills it again.
- What is loaded is what the form writes, with each relation as a ref. A
key the form marks
Entity.unmappedis not loaded and starts empty. - An invalid submit starts no mutation. The form emits nothing until every key passes the input's schema and its checks. The form's Commands are the editor's, so a check the form starts runs.
- A declared write sends only what changed. With a mutation made by
Mutation.write, a save names the input keys whose value differs, by each key's own schema, from what the row held as the form knows it: what it was filled with, and what each of this editor's saves sent once it lands. So the server writes only those, two authors of different fields both land, and a key this editor saved is not sent again over another author's later change to it. Remote shows the write bound to those keys until the server answers, with no optimistic patch written here. Something new, never filled, is written whole. - A write that
expects a revision follows its own saves. When this editor's save lands, the form takes the revision it moved the row to, once, so the next save is not refused as a conflict with itself, whether or not the author typed on while it was saving. A revision moved after that is another's, and reads asMoved, as one moved before any save does. - Status follows the author. Changing the form after a save shows
Editing(orMoved) at once; the save's own answer is still taken when it lands, and a refusal of one of its fields still shows.
Drawing it
The editor wraps the form's Model, so it takes the form's view, lifted:
import { FormView } from 'foldkit-mixins-form'
const DrawnEditor = Editor.bundle.pipe(
Bundle.withView(Crud.editorView(FormView.submodel(EditPostForm, FormView.define(EditPostForm)))),
)
// Place it as before: Bundle.withChild('editor', DrawnEditor)
// where the page draws it:
Placed.view(model, h, { options: pickers(model), words: { submit: 'Save' } })Crud.editorView works on any Submodel view of the form that takes view
inputs; foldkit-crud itself stays headless.
A key the reader may not set can be drawn but not edited. Give the editor
locks, which name members to load beside the form's and say which the row
does not let its reader set; foldkit-guard's May.locks(Post) reads the
server's may. PostEditor.locked(model) is then the form's keys to lock,
a key mapped to a relation by the relation's lock, for the form view's
locked:
const Editor = Crud.editor('PostEditor', {
form: EditPostForm,
mutation: EditPostMutation,
locks: May.locks(Post),
})
const PostEditor = Editor.at({ data: Data, model: App.model.editor })
Placed.view(model, h, { options: pickers(model), locked: PostEditor.locked(model) })Nothing is locked for something new, whose create the server checks, nor
before the row is loaded. The Source must answer what locks selects, or the
read of the row, and so the editor, fails. A lock is a hint: the server
checks the save.