Use Commands And Policy

Copy Markdown View Source

Every canvas mutation should enter LivePanels as a command.

A command is a request to change or inspect runtime state. It can come from a application function call, a browser attr, item content, graph interaction, drawing gesture, shared participant, or transaction.

LivePanels uses one command path so authorization, validation, reducers, history, persistence, shared coordination, and projection stay consistent.

Use Owner Modules From Server Code

Call the module that owns the thing you are changing:

socket = LivePanels.Canvas.Item.move_to!(socket, "note-1", {120, 80})
socket = LivePanels.Canvas.Item.resize_to!(socket, "note-1", {420, 300})
socket = LivePanels.Canvas.Item.merge_props!(socket, "note-1", %{status: "review"})
socket = LivePanels.Canvas.Selection.select!(socket, "note-1")
socket = LivePanels.Canvas.History.undo!(socket)

Graph and drawing commands use their own owner modules:

socket =
  LivePanels.Graph.connect!(socket,
    from: {"extract-1", "output"},
    to: {"transform-1", "input"}
  )

socket =
  LivePanels.Drawing.vector_item!(socket, :rectangle, %{
    space: :world,
    anchor: :top_left,
    x: 120,
    y: 80,
    w: 240,
    h: 160
  })

These calls do not patch private assigns. They submit commands to the runtime.

Use Toolkit Attrs From Vector Itemup

For browser-triggered commands, use toolkit attrs:

<button {add_item_attrs(:note)}>New note</button>
<button {remove_item_attrs(context)}>Close</button>
<button {layout_attrs(:grid)}>Layout</button>
<button {undo_attrs()}>Undo</button>

The helper emits the event name and payload fields the browser/runtime protocol expects. Your application still owns the element, label, CSS, and placement.

Use graph attrs for ports and connectors. Use drawing attrs for vector item style, erase targets, and point handles.

Commands Are Checked

A command can be rejected before it becomes state.

Examples:

  • the target item does not exist;
  • an add-item payload is malformed;
  • an item capability is missing;
  • a graph port direction or type is incompatible;
  • a drawing payload is malformed;
  • the actor is a viewer;
  • application policy rejects the request.

Use the bare command helper when application code needs to react immediately:

case LivePanels.Canvas.Item.move_by(socket, "note-1", {24, 0}) do
  {:ok, socket, _meta} ->
    {:noreply, socket}

  {:error, reason, socket, _meta} ->
    {:noreply, put_flash(socket, :error, inspect(reason))}
end

Every command owner module returns result tuples from its mutating commands: item commands, props, selection, layout, history, clipboard, viewport, graph, and drawing. Use bang variants in LiveView callbacks that should receive the next socket or raise on rejection. Browser-triggered commands still record the latest result in LivePanels.Canvas.Read.last_command/1.

Rejected commands should be visible enough to debug. They should not leave the canvas half-mutated.

Add Application Policy

Use LivePanels.Canvas.Policy when product data should decide whether a canvas mutation may happen.

defmodule MyApp.CanvasPolicy do
  @behaviour LivePanels.Canvas.Policy

  @impl true
  def authorize(%LivePanels.Canvas.Policy.Request{action: :remove_item} = request) do
    if MyApp.Workflows.item_locked?(request.canvas_id, request.item_ids, request.actor) do
      {:error, :active_workflow_reference}
    else
      :ok
    end
  end

  def authorize(_request), do: :ok
end

Configure the policy on the canvas:

use LivePanels.Canvas,
  pubsub: MyApp.PubSub,
  policy: MyApp.CanvasPolicy

The policy receives a request with the action, command, actor, canvas id, target item ids, and runtime context. Return :ok to allow the command, {:ok, metadata} to allow it with decision metadata, or {:error, reason} / {:halt, reason} to reject it.

Use policy for product rules that can answer in-process. Use item capabilities for runtime rules such as whether an item can be removed, resized, connected, or placed.

Coordinate Application Writes

When canvas state mirrors application records, run the application write in the application callback or context module, then issue LivePanels commands explicitly after that write succeeds.

def handle_event("move_card", %{"id" => id, "dx" => dx, "dy" => dy}, socket) do
  delta = {String.to_integer(dx), String.to_integer(dy)}

  case MyApp.Boards.move_card(id, delta) do
    {:ok, card} ->
      socket =
        LivePanels.Canvas.transaction!(socket, fn tx ->
          tx
          |> LivePanels.Canvas.Item.move_by!(id, delta)
          |> LivePanels.Canvas.Item.merge_props!(id, %{
            card_id: card.id,
            card_version: card.lock_version
          })
        end)

      {:noreply, socket}

    {:error, reason} ->
      {:noreply, assign(socket, :canvas_error, reason)}
  end
end

Policy remains available for acceptance rules that must apply to every command source:

use LivePanels.Canvas,
  pubsub: MyApp.PubSub,
  policy: MyApp.CanvasPolicy

In shared mode policy runs inside the canonical coordinator command path, so all participants observe the same accepted or rejected result. LivePanels validates the command before invoking policy. Policy callbacks run behind a timeout boundary because they execute in the reducer and shared coordinator command path; a timeout rejects the LivePanels command and kills the policy process.

Use Transactions For Multi-Step Work

Use LivePanels.Canvas.transaction/2 when several changes should be applied as one coordinated operation:

socket =
  LivePanels.Canvas.transaction!(socket, fn tx ->
    tx
    |> LivePanels.Canvas.Item.add!(:note, id: "note-1")
    |> LivePanels.Canvas.Item.place!("note-1", %{
      space: :world,
      anchor: :top_left,
      x: 120,
      y: 80,
      w: 360,
      h: 240
    })
    |> LivePanels.Canvas.Selection.select!("note-1")
  end)

Use transaction/2 when a composed operation needs one explicit success or failure result:

case LivePanels.Canvas.transaction(socket, fn tx ->
       LivePanels.Canvas.Item.add!(tx, :note, id: "note-1")
     end) do
  {:ok, socket, _meta} ->
    {:noreply, socket}

  {:error, reason, socket, _meta} ->
    {:noreply, put_flash(socket, :error, inspect(reason))}
end

Transactions are useful for imports, templates, graph edits, and server actions that combine item props, placement, selection, and connectors.

Keep Browser Intent Separate From State

The browser can hold transient intent while a gesture is underway.

For example, while dragging an item the browser knows the pointer position and preview frame. That is not accepted canvas state yet. The final placement becomes a command on release.

The same idea applies to graph connection drafts and drawing drafts. The user gets responsive feedback, but the server accepts the durable change.

Command Sources

Common command sources are:

They should all mean the same thing after acceptance: a command updated canvas state and the runtime projected the new state to the connected clients.

What Not To Do

Avoid these patterns:

  • replacing LivePanels assigns with your own item list;
  • changing item placement only in DOM or JavaScript;
  • hand-writing command payload attrs when a toolkit helper exists;
  • reaching into private compositor/runtime modules from application code;
  • maintaining a second graph, drawing, or layout state model next to the canvas unless it is domain data owned by your app.

Use public command helpers so local mode, shared mode, persistence, history, graph, drawing, and browser projection stay on the same contract.