Most collaborative features are not a text editor or a canvas: they are a
few values and lists that people change together. A retro board's cards
and votes, a poll, a checklist, the order of a kanban's cards, whose turn
it is. Issho.State is that, without designing a document engine: a map
that the room's authority changes in one order, as a LiveView assign that
stays up to date, which agents change with one tool.
The document
A shared state is a document of a room, with the map engine
(Issho.Engine.Map). Define it in your policy, with its initial content
from your database, and save it in its hook:
def document(_who, "retro:" <> id) do
retro = Retros.get!(id)
{:ok,
%{
engine: Issho.Engine.Map,
initial: retro.state,
on_transaction: fn _ops, %{after: state, by: who} ->
Retros.save(retro, state, who)
end
}}
endThe hook runs once for each change, before anyone sees it. It checks
what your app requires (a column's limit, who may move a card) and
answers {:error, reason} to refuse the change: the person who made it
gets the reason, and nobody else sees it.
In a LiveView
def mount(%{"id" => id}, _session, socket) do
room = "retro:" <> id
if connected?(socket),
do: {:ok, _} = Issho.Live.join(MyApp.Issho, socket.assigns.current_user, room)
{:ok, socket |> assign(:room, room) |> Issho.State.assign(:retro, room)}
end
def handle_event("add", %{"text" => text}, socket) do
card = %{"id" => Ecto.UUID.generate(), "text" => text}
:ok = Issho.State.change(socket, :retro, [Issho.State.insert(["cols", "todo"], card)])
{:noreply, socket}
end
def handle_event("move", %{"id" => id, "to" => to, "after" => after_id}, socket) do
op = Issho.State.move(["cols", "todo"], id, to: ["cols", to], after: after_id)
:ok = Issho.State.change(socket, :retro, [op])
{:noreply, socket}
end<div :for={col <- ["todo", "doing", "done"]}>
<div :for={card <- Issho.State.get(@retro, ["cols", col]) || []}>{card["text"]}</div>
</div>@retro changes when the authority accepts a change, from this LiveView
or anyone's, in the same order everywhere. In the static render, before
the LiveView is in the room, it is %{} (or :default).
Operations
| Operation | |
|---|---|
set(path, value) | the value at a path, making the maps on the way |
delete(path) | a key, or an item of a list |
increment(path, by) | adds to a number; votes and counters add up |
insert(path, item, after: id) | an item (a map with an "id") in a list, after:, before: or at: :start |
move(path, id, to: path, after: id) | an item within its list, or to another |
remove(path, id) | an item of a list |
A path goes through maps by key and through lists by an item's id:
["cols", "todo", card_id, "votes"]. Changes made at once merge: two
people who add a card both have theirs; a card placed after one that was
just deleted goes to the end; a vote for a card that was just deleted
does nothing.
Several operations given together are one change: all apply, or none.
Agents
Agents change a shared state with the update_state room tool, in the
same operations, and read it with read_document. Your server changes it
with Issho.transact/4. Each change is in your hook with who made it
(:by), whoever made it.
In the browser
A component that renders a state in the browser opens it like any
document (room.open(id)): mapEngine is the engine's twin, held to the
same cases as the server's.