MishkaCmsBuilder.Collection (Mishka CMS Builder v0.0.1-alpha.2)

Copy Markdown View Source

The list or map an attribute is holding, read out of a document and written back into one.

Why this is not a text box

chelekom-filterable-gallery keeps its whole content in two list attributes — the filter buttons and the pictures behind them — and thirty-nine attributes across the shipped kit are shaped the same way. The panel drew a one-line box for every one of them and wrote what was typed as a STRING, so a component that maps over its own attribute raised the moment anything was entered. The report reads "we cannot add any image and it's not clear how it works".

So the panel needs a control that knows it is holding a collection, and that control needs to read the list, change one item, and write the whole thing back as one edit. That is this module.

What counts as readable

MishkaCmsCore.Builder.Literal decides, and it decides by walking the quoted form rather than evaluating it: a list of strings, a list of maps, a keyword list. Anything else — a binding, a function call, an interpolation — answers :error, and a caller holding :error must leave the attribute alone rather than overwrite what it could not read. An author who has written media={@photos} means it.

And what is written back

inspect/1, which is the exact shape the editor can read again. One set_attr per change, so one undo step per add, per removal and per edit.

Lists inside items

A tree's node holds its own children, a list of nodes like it. within/3 applies a change to the list a PATH leads to — the item's index, then the field holding the next list, as deep as it goes — and puts every level around it back as it was. A map met on the way is edited as its pairs and written back a map.

A write is read as what it replaces

A box posts text, and the field it edits may hold a switch, a number or a list. Text written as it came turned a node's children into a string and the tree raised, so every setter reads the text as the kind of value already there: a switch from "true", a number when it parses, and nothing at all into a list, a map, a tuple or an atom, which have editors of their own or are the component's own words.

Summary

Functions

An empty item added to the end of the list path leads to, shaped like the ones beside it.

What an empty row of this collection looks like, read off the ones already there.

Whether an item is one side of a map or a keyword list rather than a value on its own.

The place a tuple field names: "__0", "__1"… and, for a pair, "__key" and "__value". nil for anything else.

One item changed, added or dropped, and the whole collection back.

One place of a tuple item changed, counted from 0: chelekom-headless-segmented-control takes {label, value, disabled} beside its plain words, and each place is edited as what it holds.

One field of one item changed. The key is the field's name, as an atom or as the text the browser sent: a name the item has no field for leaves the collection as it was, and no atom is made of it.

One side of a pair changed, the other left as it was.

The collection an attribute holds on this node, or the declared default when it holds nothing.

The collection after change, as the source to write back: the list read/3 gives is handed to change, and a MAP attribute is written back a map. Writing its pairs as they were edited put a list where chelekom-flash reads Phoenix.Flash.get(@flash, :info).

fun applied to the list path leads to, and the whole collection back.

The source text for a collection, ready to be written as an expression attribute.

Functions

add(items, value)

@spec add(list(), term()) :: list()

add_blank(items, path, fallback \\ "")

@spec add_blank(list(), [non_neg_integer() | String.t()], term()) :: list()

An empty item added to the end of the list path leads to, shaped like the ones beside it.

An empty nested list has nothing beside it to read, so the same field's list elsewhere in the collection answers — a node's first child takes the shape of another node's children — and failing that the list holding it, because a tree's children are shaped like their parent. fallback is the answer for the top list when it is empty, as for blank/2.

blank(items, fallback \\ "")

@spec blank(list(), term()) :: term()

What an empty row of this collection looks like, read off the ones already there.

A list of maps gets a map with the same keys blank, so the new card carries the same fields as its neighbours, and a list of tuples a tuple blank in each place; a list of anything else gets an empty string. BLANK IS THE EMPTY VALUE OF WHAT THE FIELD HOLDS: a list stays a list and a map a map, because a tree iterates its node's children and children: "" raised on the canvas; a switch is off. An empty list has nothing to read, and the caller's own sample is the answer — MishkaCmsBuilder.Starter has one for the shapes it knows.

pair?(arg1)

@spec pair?(term()) :: boolean()

Whether an item is one side of a map or a keyword list rather than a value on its own.

A map attribute is read out as its pairs — chelekom-flash holds %{"info" => "…"} and chelekom-pagination a map of query parameters — and a pair is edited as a pair, one field for each side.

place(arg1)

@spec place(term()) :: non_neg_integer() | nil

The place a tuple field names: "__0", "__1"… and, for a pair, "__key" and "__value". nil for anything else.

put(items, index, value)

@spec put(list(), non_neg_integer(), term()) :: list()

One item changed, added or dropped, and the whole collection back.

Out-of-range indexes are left alone rather than raising: the panel and the document can disagree for one render after an undo, and a stale click must not take the editor down.

put_element(items, index, position, value)

@spec put_element(list(), non_neg_integer(), non_neg_integer(), term()) :: list()

One place of a tuple item changed, counted from 0: chelekom-headless-segmented-control takes {label, value, disabled} beside its plain words, and each place is edited as what it holds.

put_in_item(items, index, key, value)

@spec put_in_item(list(), non_neg_integer(), atom() | String.t(), term()) :: list()

One field of one item changed. The key is the field's name, as an atom or as the text the browser sent: a name the item has no field for leaves the collection as it was, and no atom is made of it.

put_side(items, index, side, value)

@spec put_side(list(), non_neg_integer(), :key | :value, term()) :: list()

One side of a pair changed, the other left as it was.

read(node, name, default \\ [])

@spec read(MishkaCmsCore.Runtime.Authoring.Tree.Node.t() | term(), String.t(), term()) ::
  {:ok, list()} | :error

The collection an attribute holds on this node, or the declared default when it holds nothing.

:error when the attribute is there and is not data — a binding rather than a list.

remove(items, index)

@spec remove(list(), non_neg_integer()) :: list()

update(node, name, default, change)

@spec update(
  MishkaCmsCore.Runtime.Authoring.Tree.Node.t() | term(),
  String.t(),
  term(),
  (list() ->
     list())
) ::
  {:ok, String.t()} | :error

The collection after change, as the source to write back: the list read/3 gives is handed to change, and a MAP attribute is written back a map. Writing its pairs as they were edited put a list where chelekom-flash reads Phoenix.Flash.get(@flash, :info).

:error when the attribute is not data, as for read/3.

within(items, arg2, fun)

@spec within(list(), [non_neg_integer() | String.t()], (list() -> list())) :: list()

fun applied to the list path leads to, and the whole collection back.

A path alternates an item's index and the field of that item holding the next list — [1, "children"] is the second node's children — and the field may be a tuple's place, "__1". A map on the way is handed to fun as its pairs and written back a map. A path that no longer leads to a list or a map, which a stale click after an undo can send, leaves the collection as it was.

write(items)

@spec write(list() | map()) :: String.t()

The source text for a collection, ready to be written as an expression attribute.