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.

ts
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.editor checks, 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.bundle wraps the form's Bundle. Its Messages are the form's own, so a view dispatches EditPostForm.Message.Changed(...) exactly as before.
  • Bundle.compose states the page once, with the editor under editor. The editor's onOut is made from the Remote domain, which is made from the page's own Model, so it is given afterwards with Bundle.configure; until then the page's placements are a type error that names the editor. See composing a parent.
  • Editor.at takes the editor's slice as a ModelRef (App.model.editor) and the bound Remote domain, and returns what needs the parent:
    • onOut for the placement: a valid submit becomes Data.mutate, and the editor remembers the request id.
    • active: while an id is open, what the form writes is fetched and retained.
    • after(update): wraps update so the editor fills the form and reconciles its status after every Message. sync is that Step on its own, for an update composed another way.
    • status(model), saveError(model) while it is SaveFailed, refresh(model) to ask for the value again while it is LoadFailed, and target(model), the id being edited (null for a new one, or when closed).
  • The save is a Command that needs RemoteClient, so the page names it with Bundle.withServices<RemoteClient>().

What starts the work

CallWhat 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 onOutReturns 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:

ts
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.

ts
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

StatusWhen
Closednothing is open
Loadingan id is open and its current values have not arrived
Editingthe form is showing, with no save in progress or just settled
Savingthis editor's mutation is pending
Savedit was applied
SaveFailedit failed; the drafts are kept, and saveError(model) says why
Conflictthe server refused it with Refusal.conflict: the row moved on since it was read
Movedsomeone 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
LoadFailedreading it failed, with no earlier value to show; refresh(model) asks again
NotFoundit 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.unmapped is 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 as Moved, as one moved before any save does.
  • Status follows the author. Changing the form after a save shows Editing (or Moved) 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:

ts
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:

ts
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.