Editors of your own

Copy Markdown View Source

A cell edits with the editor of its column's type: a text box for text and numbers, the browser's date and time inputs, a toggle for booleans, a list for an enum. A column may bring its own instead, with editor:

  • a form of HEEx, a function component that the server renders in a popover under the cell. Whatever its form posts is the new value: a Koyomi picker, a Kotoba editor, a search of your users, any HEEx and any LiveView hook. No JavaScript of your own;
  • an editor of the browser's, a JavaScript function you give to configure, drawn over the cell at once: a slider, a color, a tag input.

Either way the grid keeps the rest of an edit as it does for its own editors: the changeset (or on_update, see Editing), the errors on the cell, undo and redo, the cells changed meanwhile (a compare-and-swap), "Ada is editing" for the others, and paste, fill down and Delete, which go through the column's type, not the editor.

A form of HEEx

<.data_grid id="bookings" source={Booking} repo={Repo} changeset={&Booking.changeset/2}>
  <:col field={:guest} />
  <:col field={:notes} editor={&notes_editor/1} />
</.data_grid>
defp notes_editor(assigns) do
  ~H"""
  <textarea name={@field.name} rows="4" aria-labelledby={@label_id}>{@field.value}</textarea>
  """
end

The component gets:

Assign
fielda Phoenix.HTML.FormField of the cell's value, named cell[value]: what the form posts under it is the new value
formits form
value, row, idthe cell's value and its row, as the source reads them now, and the row's id
columnthe Masu.Column
label, label_idthe column's label, and the id of an element that has it, for the input's aria-labelledby
scopethe grid's scope

What the form posts goes to the changeset as the field's params, as a form's do: text for cell[value], a map for cell[value][from] and cell[value][to], a list for cell[value][]. Your Ecto type casts it.

Kotoba and Koyomi

A Kotoba editor or a Koyomi picker posts its value as a form field, so each is one component in an editor of your own:

defp notes_editor(assigns) do
  ~H"""
  <.kotoba field={@field} id={"notes-#{@id}"} label_id={@label_id} features={~w(bold italic links lists)} />
  """
end

defp stay_editor(assigns) do
  ~H"""
  <.date_range_picker field={@field} label_id={@label_id} months={2} />
  """
end

Kotoba: rich text in cells and Koyomi: pickers for dates, times and ranges have the whole of each: the schema, what the cell shows, paste, filters, tests.

A search of the server

The component renders in the LiveView's process: a Phoenix.LiveComponent in it searches as the person types, with its own events, and puts the pick in an input of the field.

defp assignee_editor(assigns) do
  ~H"""
  <.live_component module={MyAppWeb.UserSearch} id={"assignee-#{@id}"} field={@field} scope={@scope} />
  """
end

Keys and focus

Enter, F2, a double clickopens the editor; the first control of the popover has the focus
Ctrl+Enter (⌘↩), a submit of the formsaves (Enter in a single-line input submits a form; Enter in a text area, or a rich text editor, is the editor's)
A click awaysaves
Escapelets the edit go, once the editor's own Escape (a picker's calendar) is done
Tabmoves between the controls of the popover

A form that posts what it posted when it opened saves nothing. The popover is a dialog labelled by the column's label; the focus goes back to the grid when it closes.

An editor of the browser's

Give your editors to configure, in your app's JavaScript, by name:

import { Masu, configure } from "masu"

configure({
  editors: {
    score: ({ element, value, options, label }) => {
      const input = Object.assign(document.createElement("input"), {
        type: "range", min: options.min ?? 0, max: options.max ?? 100, value: value ?? 0,
      })
      input.setAttribute("aria-label", label)
      // The arrows are the slider's.
      input.addEventListener("keydown", (event) => {
        if (event.key.startsWith("Arrow")) event.stopPropagation()
      })
      element.append(input)
      return { value: () => Number(input.value), focus: () => input.focus() }
    },
  },
})
<:col field={:score} type={:integer} editor={{"score", %{max: 100}}} />
<:col field={:color} editor="color" />

The function gets a context, and gives the editor:

The context
elementa box over the cell, as wide as the column and as tall as the row, to draw in
value, textthe cell's value as the browser has it, and the character typed that opened the editor
id, rowthe row's id, and its values by column key
column, options, label, localethe column, the options of its editor, its label, the grid's locale
commit(value, move)saves a value; move is "down", "up", "right" or "left"
cancel()lets the edit go
The editor
value()the value now, which the grid saves on Enter, Tab and a click away
focus()takes the focus (the first control of element does unless given)
contains(node)whether a node outside element is the editor's (a list it put in document.body)
destroy()called when the editor closes

The keys an editor handles, it prevents (event.preventDefault()) or stops: the grid acts on the others: Enter saves and moves down (Shift+Enter, up), Tab right (Shift+Tab, left), Escape lets the edit go. The grid checks the value by the column's type before it saves it, as it does for its own editors, and the changeset has the last word.

An editor that is not given to configure leaves the column with the editor of its type, and says so in the browser's console.

Values of your own types: Masu.Wire

A cell shows its value as the browser gets it: numbers, decimals, dates and times, booleans, text, and structs that implement String.Chars. For another type, implement Masu.Wire: what the cell shows, what a copy and an export take.

defimpl Masu.Wire, for: Kotoba.Content do
  def to_wire(content), do: content.text
end

# Koyomi.DateRange's values are Date.Range.
defimpl Masu.Wire, for: Date.Range do
  def to_wire(range), do: "#{range.first} → #{range.last}"
end

The editor gets the value as the row has it, not this. A column of such values sorts and filters only as far as its source can: give it sortable={false} and filterable={false} unless its database column does.

New rows

A new row, until it saves, has no id for the server to find: its cells edit with the editors of their types.

Testing

view = open_editor(view, "bookings", 1, :notes)
assert has_element?(view, ~s(#bookings-cell-editor textarea[name="cell[value]"]))

assert :ok = edit(view, "bookings", [{1, :notes, "Late check-in"}])
assert :ok = edit(view, "bookings", [{1, :stay, %{"from" => "2027-03-15", "to" => "2027-03-20"}}])

open_editor/4 renders the form, as the browser's Enter does; edit/4 saves what it would post, as the browser does. An editor of the browser's is tested in the browser.