defmodule WaxxWeb.TemplateLive.Edit do
use WaxxWeb, :live_view
alias Waxx.Workflows
# Layout constants used by both the HEEx template and the SVG arrow paths.
# The graph flows top-to-bottom: stages stack vertically, forward arrows
# run down the centerline, backward arrows loop out to the right.
@node_w 220
@node_h 64
@node_gap 56
@node_left 20
@node_top 20
# Extra horizontal room to the right of the node column so backward-loop
# arrows have somewhere to bulge into.
@loop_pad 140
@impl true
def mount(%{"id" => id}, _session, socket) do
template = Workflows.get_template!(id)
{:ok,
socket
|> assign(:page_title, template.name)
|> assign(:template, template)
|> assign(:active_tab, :workflow)
|> assign(:stage_form, to_form(%{"name" => "", "color" => ""}, as: "stage"))
|> assign(:label_form, to_form(%{"name" => "", "color" => ""}, as: "label"))
|> assign(:field_form, blank_field_form())
|> assign(:selected_source_id, nil)
|> assign(:editing_stage_id, nil)}
end
@impl true
def handle_params(params, _uri, socket) do
{:noreply, assign(socket, :active_tab, parse_tab(params["tab"]))}
end
defp parse_tab("labels"), do: :labels
defp parse_tab("fields"), do: :fields
defp parse_tab(_), do: :workflow
defp blank_field_form do
to_form(
%{
"name" => "",
"kind" => "text",
"options" => "",
"show_on_card" => "false"
},
as: "field"
)
end
## --- stage events --------------------------------------------------------
@impl true
def handle_event("add_stage", %{"stage" => params}, socket) do
case Workflows.add_stage(socket.assigns.template, params) do
{:ok, _stage} ->
{:noreply,
socket
|> refresh_template()
|> assign(:stage_form, to_form(%{"name" => "", "color" => ""}, as: "stage"))
|> put_flash(:info, "Stage added.")}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Could not add stage.")}
end
end
def handle_event("edit_stage", %{"id" => id}, socket) do
{:noreply, assign(socket, :editing_stage_id, id)}
end
def handle_event("cancel_stage_edit", _, socket) do
{:noreply, assign(socket, :editing_stage_id, nil)}
end
def handle_event("rename_stage", %{"id" => id, "name" => new_name}, socket) do
stage = Enum.find(socket.assigns.template.stages, &(&1.id == id))
new_name = (new_name || "") |> String.trim()
cond do
is_nil(stage) ->
{:noreply, assign(socket, :editing_stage_id, nil)}
new_name == "" ->
{:noreply, put_flash(socket, :error, "Stage name can't be blank.")}
new_name == stage.name ->
# No-op: avoid an extra propagation pass.
{:noreply, assign(socket, :editing_stage_id, nil)}
true ->
case Workflows.update_stage(stage, %{"name" => new_name}) do
{:ok, _} ->
{:noreply,
socket
|> refresh_template()
|> assign(:editing_stage_id, nil)
|> put_flash(:info, "Renamed.")}
{:error, cs} ->
{:noreply, put_flash(socket, :error, "Could not rename: #{format_errors(cs)}")}
end
end
end
def handle_event("delete_stage", %{"id" => id}, socket) do
stage = Enum.find(socket.assigns.template.stages, &(&1.id == id))
if stage do
{:ok, _} = Workflows.delete_stage(stage)
# If the deleted stage was the selected source, clear the selection.
selected =
if socket.assigns.selected_source_id == stage.id,
do: nil,
else: socket.assigns.selected_source_id
{:noreply, socket |> refresh_template() |> assign(:selected_source_id, selected)}
else
{:noreply, socket}
end
end
## --- transition / graph events ------------------------------------------
# Single click on a stage node:
# * with no source selected → select it
# * with the same source selected → deselect
# * with another source selected → create source→this transition
def handle_event("select_source", %{"id" => raw_id}, socket) do
id = raw_id
template = socket.assigns.template
case socket.assigns.selected_source_id do
nil ->
{:noreply, assign(socket, :selected_source_id, id)}
^id ->
{:noreply, assign(socket, :selected_source_id, nil)}
from_id ->
case Workflows.add_transition(template, from_id, id) do
{:ok, _} ->
{:noreply,
socket
|> refresh_template()
|> assign(:selected_source_id, nil)
|> put_flash(:info, "Transition added.")}
{:error, %Ecto.Changeset{} = cs} ->
{:noreply,
socket
|> assign(:selected_source_id, nil)
|> put_flash(:error, "Could not add transition: #{format_errors(cs)}")}
end
end
end
def handle_event("clear_selection", _, socket) do
{:noreply, assign(socket, :selected_source_id, nil)}
end
def handle_event("delete_transition", %{"id" => id}, socket) do
transition =
Enum.find(socket.assigns.template.transitions, &(&1.id == id))
if transition do
{:ok, _} = Workflows.delete_transition(transition)
{:noreply, refresh_template(socket)}
else
{:noreply, socket}
end
end
def handle_event("add_label", %{"label" => params}, socket) do
case Workflows.add_label(socket.assigns.template, params) do
{:ok, _label} ->
{:noreply,
socket
|> refresh_template()
|> assign(:label_form, to_form(%{"name" => "", "color" => ""}, as: "label"))
|> put_flash(:info, "Label added.")}
{:error, cs} ->
{:noreply, put_flash(socket, :error, "Could not add label: #{format_errors(cs)}")}
end
end
def handle_event("delete_label", %{"id" => id}, socket) do
label = Enum.find(socket.assigns.template.labels, &(&1.id == id))
if label do
{:ok, _} = Workflows.delete_label(label)
{:noreply, refresh_template(socket)}
else
{:noreply, socket}
end
end
def handle_event("add_field", %{"field" => params}, socket) do
attrs = %{
"name" => params["name"],
"kind" => params["kind"],
"show_on_card" => params["show_on_card"] == "true",
"options" => parse_options(params["options"])
}
case Workflows.add_field(socket.assigns.template, attrs) do
{:ok, _f} ->
{:noreply,
socket
|> refresh_template()
|> assign(:field_form, blank_field_form())
|> put_flash(:info, "Field added.")}
{:error, cs} ->
{:noreply, put_flash(socket, :error, "Could not add field: #{format_errors(cs)}")}
end
end
def handle_event("delete_field", %{"id" => id}, socket) do
field = Enum.find(socket.assigns.template.fields, &(&1.id == id))
if field do
{:ok, _} = Workflows.delete_field(field)
{:noreply, refresh_template(socket)}
else
{:noreply, socket}
end
end
def handle_event("toggle_field_show", %{"id" => id}, socket) do
field = Enum.find(socket.assigns.template.fields, &(&1.id == id))
if field do
case Workflows.update_field(field, %{"show_on_card" => not field.show_on_card}) do
{:ok, _} -> {:noreply, refresh_template(socket)}
{:error, _} -> {:noreply, put_flash(socket, :error, "Could not update field.")}
end
else
{:noreply, socket}
end
end
defp parse_options(nil), do: []
defp parse_options(""), do: []
defp parse_options(s) when is_binary(s) do
s
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp refresh_template(socket) do
assign(socket, :template, Workflows.get_template!(socket.assigns.template.id))
end
defp format_errors(%Ecto.Changeset{errors: errors}) do
errors
|> Enum.map_join(", ", fn {field, {msg, _}} -> "#{field} #{msg}" end)
end
## --- graph layout helpers (used in HEEx) --------------------------------
defp stage_index(stages, stage_id), do: Enum.find_index(stages, &(&1.id == stage_id))
defp node_top(idx), do: idx * (@node_h + @node_gap) + @node_top
defp node_bottom(idx), do: node_top(idx) + @node_h
defp node_center_x, do: @node_left + div(@node_w, 2)
defp node_right, do: @node_left + @node_w
defp node_middle_y(idx), do: node_top(idx) + div(@node_h, 2)
defp canvas_width, do: @node_left + @node_w + @loop_pad
defp canvas_height(stages),
do: max(length(stages) * (@node_h + @node_gap) + 2 * @node_top, 200)
# Returns SVG path string for a curved arrow between two stage indexes.
# Forward arrows run down the column centerline; skip-ahead arrows bow
# right so they're visibly distinct from adjacent ones. Backward arrows
# loop out to the right of the column.
defp arrow_path(sidx, tidx) when sidx < tidx do
sx = node_center_x()
sy = node_bottom(sidx)
tx = node_center_x()
ty = node_top(tidx)
skip = tidx - sidx
bow = if skip == 1, do: 0, else: min(@loop_pad - 20, skip * 22)
cx1 = sx + bow
cx2 = tx + bow
"M #{sx} #{sy} C #{cx1} #{sy + 20}, #{cx2} #{ty - 20}, #{tx} #{ty}"
end
defp arrow_path(sidx, tidx) when sidx > tidx do
sx = node_right()
sy = node_middle_y(sidx)
tx = node_right()
ty = node_middle_y(tidx)
loop_x = sx + @loop_pad - 40
"M #{sx} #{sy} C #{loop_x} #{sy}, #{loop_x} #{ty}, #{tx} #{ty}"
end
## --- template ------------------------------------------------------------
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:node_w, @node_w)
|> assign(:node_h, @node_h)
|> assign(:node_left, @node_left)
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<div class="max-w-3xl mx-auto py-6 space-y-6">
<header class="flex items-center justify-between">
<div>
<.link navigate={~p"/workflow-templates"} class="text-xs opacity-60 hover:underline">
← All templates
</.link>
<h1 class="text-2xl font-bold mt-1">{@template.name}</h1>
<p :if={@template.description} class="text-sm opacity-70">
{@template.description}
</p>
</div>
</header>
<div role="tablist" class="tabs tabs-bordered">
<.link
patch={~p"/workflow-templates/#{@template.id}"}
role="tab"
class={["tab", @active_tab == :workflow && "tab-active"]}
>
<.icon name="hero-arrows-right-left-micro" class="size-4 mr-1" /> Workflow
<span class="badge badge-ghost badge-xs ml-2">
{length(@template.stages)}
</span>
</.link>
<.link
patch={~p"/workflow-templates/#{@template.id}?tab=labels"}
role="tab"
class={["tab", @active_tab == :labels && "tab-active"]}
>
<.icon name="hero-tag-micro" class="size-4 mr-1" /> Labels
<span class="badge badge-ghost badge-xs ml-2">
{length(@template.labels)}
</span>
</.link>
<.link
patch={~p"/workflow-templates/#{@template.id}?tab=fields"}
role="tab"
class={["tab", @active_tab == :fields && "tab-active"]}
>
<.icon name="hero-document-text-micro" class="size-4 mr-1" /> Fields
<span class="badge badge-ghost badge-xs ml-2">
{length(@template.fields)}
</span>
</.link>
</div>
<section :if={@active_tab == :workflow}>
<div class="flex items-baseline justify-between mb-2">
<h2 class="text-lg font-semibold">Workflow graph</h2>
<p class="text-xs opacity-70">
Click a stage to pick the source, then click another stage to draw a transition.
Click an arrow to remove it.
</p>
</div>
<%= if @template.stages == [] do %>
<div class="border border-base-300 border-dashed rounded-box p-8 bg-base-200 text-center text-sm opacity-60">
Add a stage below to start building the graph.
</div>
<% else %>
<div class="border border-base-300 rounded-box bg-base-200 p-4">
<div
class="relative mx-auto"
style={"width: #{canvas_width()}px; height: #{canvas_height(@template.stages)}px;"}
>
<svg
class="absolute inset-0 pointer-events-none text-base-content/60"
width={canvas_width()}
height={canvas_height(@template.stages)}
>
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L9,3 z" fill="currentColor" />
</marker>
</defs>
<g>
<%= for t <- @template.transitions do %>
<% sidx = stage_index(@template.stages, t.from_stage_id) %>
<% tidx = stage_index(@template.stages, t.to_stage_id) %>
<%= if sidx && tidx do %>
<path
id={"arrow-#{t.id}"}
d={arrow_path(sidx, tidx)}
fill="none"
stroke="currentColor"
stroke-width="2"
marker-end="url(#arrowhead)"
style="pointer-events: stroke; cursor: pointer;"
phx-click="delete_transition"
phx-value-id={t.id}
data-confirm="Remove this transition?"
class="hover:text-error transition-colors"
>
<title>
{transition_label(t)} — click to remove
</title>
</path>
<% end %>
<% end %>
</g>
</svg>
<%= for {stage, idx} <- Enum.with_index(@template.stages) do %>
<button
type="button"
id={"stage-node-#{stage.id}"}
phx-click="select_source"
phx-value-id={stage.id}
class={[
"absolute z-20 flex items-center justify-center gap-2 rounded-box bg-base-100 shadow-sm",
"border-2 transition-colors",
if(@selected_source_id == stage.id,
do: "border-primary ring-2 ring-primary",
else: "border-base-300 hover:border-primary"
)
]}
style={"left: #{@node_left}px; top: #{node_top(idx)}px; width: #{@node_w}px; height: #{@node_h}px;"}
>
<span
:if={stage.color && stage.color != ""}
class="size-3 rounded-full border border-base-content/20 shrink-0"
style={"background: #{stage.color}"}
/>
<span class="font-semibold text-sm text-center px-2 truncate">
{stage.name}
</span>
</button>
<% end %>
</div>
</div>
<div class="flex items-center justify-between mt-2 min-h-[1.5rem]">
<p :if={@selected_source_id} class="text-xs text-primary">
<.icon name="hero-cursor-arrow-rays-micro" class="size-3" />
Source selected. Click another stage to connect, or <button
type="button"
phx-click="clear_selection"
class="underline hover:text-error"
>cancel</button>.
</p>
<p class="text-xs opacity-60 ml-auto">
{length(@template.stages)} stages · {length(@template.transitions)} transitions
</p>
</div>
<% end %>
</section>
<section :if={@active_tab == :workflow}>
<h2 class="text-lg font-semibold mb-2">Stages</h2>
<p class="text-sm opacity-70 mb-3">
Stages render as nodes in the graph above, in this order.
</p>
<ul class="flex flex-col gap-2 mb-3">
<li
:for={stage <- @template.stages}
id={"stage-#{stage.id}"}
class="border border-base-300 rounded-box p-2 bg-base-200 flex items-center gap-3"
>
<span class="font-mono text-xs opacity-60 w-6 text-right">{stage.position}</span>
<span
:if={stage.color && stage.color != ""}
class="size-3 rounded-full border border-base-content/20"
style={"background: #{stage.color}"}
/>
<%= if @editing_stage_id == stage.id do %>
<.form
for={%{}}
as={:stage}
phx-submit="rename_stage"
phx-value-id={stage.id}
class="flex-1 flex items-center gap-1"
>
<input
type="text"
name="name"
value={stage.name}
required
phx-mounted={JS.focus()}
phx-key="escape"
phx-keydown="cancel_stage_edit"
class="input input-xs input-bordered flex-1"
/>
<button type="submit" class="btn btn-ghost btn-xs" title="Save">
<.icon name="hero-check-micro" class="size-4" />
</button>
<button
type="button"
phx-click="cancel_stage_edit"
class="btn btn-ghost btn-xs"
title="Cancel"
>
<.icon name="hero-x-mark-micro" class="size-4" />
</button>
</.form>
<% else %>
<span class="flex-1">{stage.name}</span>
<button
type="button"
phx-click="edit_stage"
phx-value-id={stage.id}
class="btn btn-ghost btn-xs"
title="Rename"
>
<.icon name="hero-pencil-square-micro" class="size-4" />
</button>
<button
type="button"
phx-click="delete_stage"
phx-value-id={stage.id}
data-confirm="Delete this stage? All transitions touching it will be removed too."
class="btn btn-ghost btn-xs text-error"
>
<.icon name="hero-trash-micro" class="size-4" />
</button>
<% end %>
</li>
</ul>
<.form
for={@stage_form}
id="stage-form"
phx-submit="add_stage"
class="flex flex-col gap-3"
>
<div class="flex items-end gap-2">
<.input field={@stage_form[:name]} type="text" label="Stage name" required />
<.button class="btn btn-primary">Add stage</.button>
</div>
<.color_picker field={@stage_form[:color]} label="Color (optional)" />
</.form>
</section>
<section :if={@active_tab == :labels}>
<h2 class="text-lg font-semibold mb-2">Labels</h2>
<p class="text-sm opacity-70 mb-3">
Labels are cloned to every board created from this template. Adding
or removing a label here propagates to existing boards — a board
won't lose a label that's still attached to a card.
</p>
<ul class="flex flex-wrap gap-2 mb-3">
<li
:for={lab <- @template.labels}
id={"label-#{lab.id}"}
class="border border-base-300 rounded-box pl-2 pr-1 py-1 bg-base-200 flex items-center gap-2"
>
<span
:if={lab.color && lab.color != ""}
class="size-3 rounded-full border border-base-content/20"
style={"background: #{lab.color}"}
/>
<span class="text-sm">{lab.name}</span>
<button
type="button"
phx-click="delete_label"
phx-value-id={lab.id}
data-confirm="Remove this label?"
class="btn btn-ghost btn-xs text-error"
aria-label="Delete label"
>
<.icon name="hero-x-mark-micro" class="size-3" />
</button>
</li>
<li
:if={@template.labels == []}
class="text-sm opacity-60"
>
No labels yet.
</li>
</ul>
<.form
for={@label_form}
id="label-form"
phx-submit="add_label"
class="flex flex-col gap-3"
>
<div class="flex items-end gap-2">
<.input field={@label_form[:name]} type="text" label="Label name" required />
<.button class="btn btn-primary">Add label</.button>
</div>
<.color_picker field={@label_form[:color]} label="Color (optional)" />
</.form>
</section>
<section :if={@active_tab == :fields}>
<h2 class="text-lg font-semibold mb-2">Custom fields</h2>
<p class="text-sm opacity-70 mb-3">
Custom info attached to each card — due dates, locations, anything
you want. Toggle "show on card" to surface a field as a small chip
on the kanban tile.
</p>
<ul class="flex flex-col gap-2 mb-3">
<li
:for={f <- @template.fields}
id={"field-#{f.id}"}
class="border border-base-300 rounded-box p-2 bg-base-200 flex items-center gap-3"
>
<span class="badge badge-ghost badge-sm font-mono">{f.kind}</span>
<span class="font-medium">{f.name}</span>
<span
:if={f.kind == "select" and f.options not in [nil, []]}
class="text-xs opacity-70 truncate"
>
{Enum.join(f.options, ", ")}
</span>
<span class="flex-1" />
<label class="label cursor-pointer gap-2">
<span class="label-text text-xs">Show on card</span>
<input
type="checkbox"
class="checkbox checkbox-sm"
checked={f.show_on_card}
phx-click="toggle_field_show"
phx-value-id={f.id}
/>
</label>
<button
type="button"
phx-click="delete_field"
phx-value-id={f.id}
data-confirm="Delete this field? Existing values on cards will be kept on boards that still use it."
class="btn btn-ghost btn-xs text-error"
aria-label="Delete field"
>
<.icon name="hero-trash-micro" class="size-4" />
</button>
</li>
<li :if={@template.fields == []} class="text-sm opacity-60">
No custom fields yet.
</li>
</ul>
<.form
for={@field_form}
id="field-form"
phx-submit="add_field"
class="grid grid-cols-1 sm:grid-cols-4 gap-2 items-end"
>
<.input field={@field_form[:name]} type="text" label="Field name" required />
<.input
field={@field_form[:kind]}
type="select"
label="Type"
options={[
{"Text", "text"},
{"Date", "date"},
{"Date + time", "datetime"},
{"Select (pick one)", "select"}
]}
/>
<.input
field={@field_form[:options]}
type="text"
label="Options (select only, comma-separated)"
/>
<.button class="btn btn-primary">Add field</.button>
<label class="label cursor-pointer gap-2 sm:col-span-4">
<input
type="checkbox"
name="field[show_on_card]"
value="true"
class="checkbox checkbox-sm"
/>
<span class="label-text">Show on card</span>
</label>
</.form>
</section>
</div>
</Layouts.app>
"""
end
defp transition_label(%{label: nil, from_stage: f, to_stage: t}), do: "#{f.name} → #{t.name}"
defp transition_label(%{label: label, from_stage: f, to_stage: t}),
do: "#{f.name} → #{t.name} (#{label})"
end
neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
24.9 KiB · text
History
a2252b9