hmm

66113d8 · gmorell · 2026-05-21 04:43

20 files +691 -8

Files changed

modified lib/waxx/application.ex
+10 −3
@@ -8,9 +8,16 @@ defmodule Waxx.Application do
8 8 @impl true
9 9 def start(_type, _args) do
10 10 # Forward warnings + errors to Sentry. No-ops when SENTRY_DSN is unset.
11 − :logger.add_handler(:sentry_handler, Sentry.LoggerHandler, %{
12 − config: %{metadata: [:file, :line]}
13 − })
11 + # We wrap the add_handler call so a bad Sentry config can't take the
12 + # whole VM down at boot — the worst case is "no Sentry today".
13 + try do
14 + :logger.add_handler(:sentry_handler, Sentry.LoggerHandler, %{
15 + config: %{metadata: [:file, :line]}
16 + })
17 + rescue
18 + e ->
19 + IO.warn("[waxx] sentry handler not attached: #{Exception.message(e)}")
20 + end
14 21
15 22 topologies = Application.get_env(:libcluster, :topologies, [])
16 23
modified lib/waxx/kanban.ex
+206 −2
@@ -30,7 +30,9 @@ defmodule Waxx.Kanban do
30 30 Card,
31 31 CardAssignee,
32 32 CardLabel,
33 − CardFieldValue
33 + CardFieldValue,
34 + CardNote,
35 + CardTemplate
34 36 }
35 37
36 38 @pubsub Waxx.PubSub
@@ -718,7 +720,7 @@ defmodule Waxx.Kanban do
718 720 from(c in Card,
719 721 where: c.board_id == ^board.id,
720 722 order_by: [asc: c.board_stage_id, asc: c.position, asc: c.inserted_at],
721 − preload: [:assignees, :labels, :field_values, :created_by]
723 + preload: [:assignees, :labels, :field_values, :notes, :created_by]
722 724 )
723 725 |> Repo.all()
724 726
@@ -760,6 +762,7 @@ defmodule Waxx.Kanban do
760 762 :assignees,
761 763 :labels,
762 764 :field_values,
765 + :notes,
763 766 :created_by,
764 767 :board_stage
765 768 ])
@@ -985,6 +988,207 @@ defmodule Waxx.Kanban do
985 988 |> broadcast_on_ok(card.board_id)
986 989 end
987 990
991 + ## Card notes + todos -------------------------------------------------
992 +
993 + @doc """
994 + Adds a freeform note or todo to a card. Auto-stamps the card's current
995 + `board_stage_id` so the entry renders in that stage's colour in the
996 + card detail view — useful as a "what was happening in this stage"
997 + journal even after the card has moved on.
998 + """
999 + def add_card_note(%Card{} = card, user, attrs) do
1000 + next_pos =
1001 + (Repo.one(
1002 + from(n in CardNote,
1003 + where: n.card_id == ^card.id,
1004 + select: max(n.position)
1005 + )
1006 + ) || -1) + 1
1007 +
1008 + attrs =
1009 + attrs
1010 + |> stringify_keys()
1011 + |> Map.put("card_id", card.id)
1012 + |> Map.put_new("board_stage_id", card.board_stage_id)
1013 + |> Map.put_new("position", next_pos)
1014 + |> Map.put_new("created_by_id", actor_id(user))
1015 +
1016 + case %CardNote{} |> CardNote.changeset(attrs) |> Repo.insert() do
1017 + {:ok, _} = result ->
1018 + broadcast_cards_changed(card.board_id)
1019 + result
1020 +
1021 + other ->
1022 + other
1023 + end
1024 + end
1025 +
1026 + def toggle_card_note_done(%CardNote{} = note) do
1027 + case note
1028 + |> CardNote.changeset(%{done: not note.done})
1029 + |> Repo.update() do
1030 + {:ok, updated} = result ->
1031 + if card = Repo.get(Card, updated.card_id), do: broadcast_cards_changed(card.board_id)
1032 + result
1033 +
1034 + other ->
1035 + other
1036 + end
1037 + end
1038 +
1039 + def update_card_note(%CardNote{} = note, attrs) do
1040 + case note |> CardNote.changeset(attrs) |> Repo.update() do
1041 + {:ok, updated} = result ->
1042 + if card = Repo.get(Card, updated.card_id), do: broadcast_cards_changed(card.board_id)
1043 + result
1044 +
1045 + other ->
1046 + other
1047 + end
1048 + end
1049 +
1050 + def delete_card_note(%CardNote{card_id: card_id} = note) do
1051 + case Repo.delete(note) do
1052 + {:ok, _} = result ->
1053 + if card = Repo.get(Card, card_id), do: broadcast_cards_changed(card.board_id)
1054 + result
1055 +
1056 + other ->
1057 + other
1058 + end
1059 + end
1060 +
1061 + ## Card templates -----------------------------------------------------
1062 +
1063 + def list_card_templates(%Board{id: board_id}) do
1064 + Repo.all(from(t in CardTemplate, where: t.board_id == ^board_id, order_by: [asc: t.name]))
1065 + end
1066 +
1067 + def get_card_template(id) do
1068 + Repo.get(CardTemplate, id)
1069 + end
1070 +
1071 + @doc """
1072 + Snapshots a card into a per-board template. The snapshot stores label
1073 + *names* and field *names* (instead of ids) so the template stays
1074 + valid after rename. Notes/todos are captured too. Assignees and
1075 + stage/subboard are deliberately NOT snapshotted — they're context.
1076 + """
1077 + def save_card_as_template(%Card{} = card, user, name) do
1078 + card =
1079 + Repo.preload(card, [
1080 + :labels,
1081 + field_values: [:board_field],
1082 + notes: [:board_stage]
1083 + ])
1084 +
1085 + snapshot = %{
1086 + "title" => card.title,
1087 + "description" => card.description,
1088 + "label_names" => Enum.map(card.labels, & &1.name),
1089 + "field_values" => for(v <- card.field_values, into: %{}, do: {v.board_field.name, v.value}),
1090 + "notes" =>
1091 + for n <- card.notes do
1092 + %{
1093 + "kind" => n.kind,
1094 + "body" => n.body,
1095 + "done" => n.done,
1096 + "stage_name" => n.board_stage && n.board_stage.name
1097 + }
1098 + end
1099 + }
1100 +
1101 + %CardTemplate{}
1102 + |> CardTemplate.changeset(%{
1103 + board_id: card.board_id,
1104 + name: name,
1105 + snapshot: snapshot,
1106 + created_by_id: actor_id(user)
1107 + })
1108 + |> Repo.insert()
1109 + end
1110 +
1111 + @doc """
1112 + Creates a new card on `board` seeded from the given template. Resolves
1113 + label and field references by name against the destination board's
1114 + current labels/fields; unknown ones are silently skipped. The new
1115 + card lands at the given stage/subboard like a normal `create_card`
1116 + call (the snapshot doesn't carry stage location).
1117 + """
1118 + def create_card_from_template(
1119 + %Board{} = board,
1120 + user,
1121 + %CardTemplate{snapshot: snap},
1122 + extra_attrs \\ %{}
1123 + ) do
1124 + base_attrs =
1125 + %{
1126 + "title" => snap["title"] || "Untitled",
1127 + "description" => snap["description"]
1128 + }
1129 + |> Map.merge(stringify_keys(extra_attrs))
1130 +
1131 + Repo.transact(fn ->
1132 + with {:ok, card} <- create_card(board, user, base_attrs) do
1133 + board = Repo.preload(board, [:labels, :fields])
1134 + apply_label_names(card, board, snap["label_names"] || [])
1135 + apply_field_values(card, board, snap["field_values"] || %{})
1136 + apply_template_notes(card, board, snap["notes"] || [], user)
1137 + {:ok, get_card(card.id)}
1138 + end
1139 + end)
1140 + end
1141 +
1142 + def delete_card_template(%CardTemplate{} = tpl), do: Repo.delete(tpl)
1143 +
1144 + defp apply_label_names(card, board, names) do
1145 + labels_by_name = Map.new(board.labels, &{&1.name, &1})
1146 +
1147 + for n <- names, label = labels_by_name[n], not is_nil(label) do
1148 + %CardLabel{}
1149 + |> CardLabel.changeset(%{card_id: card.id, board_label_id: label.id})
1150 + |> Repo.insert(on_conflict: :nothing, conflict_target: [:card_id, :board_label_id])
1151 + end
1152 + end
1153 +
1154 + defp apply_field_values(card, board, values) do
1155 + fields_by_name = Map.new(board.fields, &{&1.name, &1})
1156 +
1157 + for {name, value} <- values, field = fields_by_name[name], not is_nil(field) do
1158 + set_card_field_value(card, field, value)
1159 + end
1160 + end
1161 +
1162 + defp apply_template_notes(card, board, notes, user) do
1163 + stages_by_name = Map.new(board.stages || [], &{&1.name, &1})
1164 +
1165 + # If the board snapshot didn't preload stages, get them.
1166 + stages_by_name =
1167 + if map_size(stages_by_name) > 0 do
1168 + stages_by_name
1169 + else
1170 + board
1171 + |> Repo.preload(:stages)
1172 + |> Map.get(:stages)
1173 + |> Map.new(&{&1.name, &1})
1174 + end
1175 +
1176 + for n <- notes do
1177 + stage_id =
1178 + case n["stage_name"] && stages_by_name[n["stage_name"]] do
1179 + %BoardStage{id: id} -> id
1180 + _ -> card.board_stage_id
1181 + end
1182 +
1183 + add_card_note(card, user, %{
1184 + "body" => n["body"],
1185 + "kind" => n["kind"] || "note",
1186 + "done" => n["done"] == true,
1187 + "board_stage_id" => stage_id
1188 + })
1189 + end
1190 + end
1191 +
988 1192 ## helpers (positioning) -----------------------------------------------
989 1193
990 1194 defp next_position_in(board_id, stage_id) do
modified lib/waxx/kanban/board.ex
+2 −0
@@ -11,6 +11,7 @@ defmodule Waxx.Kanban.Board do
11 11 BoardLabel,
12 12 BoardField,
13 13 BoardActivity,
14 + CardTemplate,
14 15 Subboard,
15 16 Membership,
16 17 BoardInvite,
@@ -37,6 +38,7 @@ defmodule Waxx.Kanban.Board do
37 38 has_many :invites, BoardInvite
38 39 has_many :cards, Card
39 40 has_many :activities, BoardActivity
41 + has_many :card_templates, CardTemplate, preload_order: [asc: :name]
40 42
41 43 timestamps(type: :utc_datetime)
42 44 end
modified lib/waxx/kanban/card.ex
+2 −0
@@ -11,6 +11,7 @@ defmodule Waxx.Kanban.Card do
11 11 CardAssignee,
12 12 CardLabel,
13 13 CardFieldValue,
14 + CardNote,
14 15 Subboard
15 16 }
16 17
@@ -36,6 +37,7 @@ defmodule Waxx.Kanban.Card do
36 37 many_to_many :labels, BoardLabel, join_through: CardLabel, on_replace: :delete
37 38
38 39 has_many :field_values, CardFieldValue
40 + has_many :notes, CardNote, preload_order: [asc: :position, asc: :inserted_at]
39 41
40 42 timestamps(type: :utc_datetime)
41 43 end
added lib/waxx/kanban/card_note.ex
+48 −0
@@ -0,0 +1,48 @@
1 +defmodule Waxx.Kanban.CardNote do
2 + @moduledoc """
3 + An ad-hoc note or todo item attached to a card. Each row remembers
4 + which stage it was created in — the kanban view renders it with that
5 + stage's color even after the card has moved on, so the journal stays
6 + legible. `kind` discriminates between freeform notes and checkable
7 + todos; `done` is only meaningful for `"todo"` rows.
8 + """
9 + use Waxx.Schema
10 + import Ecto.Changeset
11 +
12 + alias Waxx.Accounts.User
13 + alias Waxx.Kanban.{Card, BoardStage}
14 +
15 + @kinds ~w(note todo)
16 +
17 + schema "card_notes" do
18 + field :body, :string
19 + field :kind, :string, default: "note"
20 + field :done, :boolean, default: false
21 + field :position, :integer, default: 0
22 +
23 + belongs_to :card, Card
24 + belongs_to :board_stage, BoardStage
25 + belongs_to :created_by, User
26 +
27 + timestamps(type: :utc_datetime)
28 + end
29 +
30 + def kinds, do: @kinds
31 +
32 + def changeset(note, attrs) do
33 + note
34 + |> cast(attrs, [
35 + :card_id,
36 + :board_stage_id,
37 + :body,
38 + :kind,
39 + :done,
40 + :position,
41 + :created_by_id
42 + ])
43 + |> validate_required([:card_id, :body, :kind])
44 + |> validate_inclusion(:kind, @kinds)
45 + |> validate_length(:body, max: 2000)
46 + |> check_constraint(:kind, name: :card_notes_kind_check)
47 + end
48 +end
added lib/waxx/kanban/card_template.ex
+43 −0
@@ -0,0 +1,43 @@
1 +defmodule Waxx.Kanban.CardTemplate do
2 + @moduledoc """
3 + Per-board snapshot of a card that can be used to seed new cards.
4 +
5 + The `snapshot` map captures everything portable about a card:
6 +
7 + %{
8 + "title" => "...",
9 + "description" => "...",
10 + "label_names" => ["bug", "urgent"],
11 + "field_values" => %{"due" => "...", "location" => "..."},
12 + "notes" => [%{"kind" => "todo", "body" => "...", "done" => false}, ...]
13 + }
14 +
15 + Label and field references are stored by *name* so the snapshot
16 + survives renames on the board side — they resolve at creation time.
17 + Card-template rows live on a single board; copying a template to
18 + another board is a future feature.
19 + """
20 + use Waxx.Schema
21 + import Ecto.Changeset
22 +
23 + alias Waxx.Accounts.User
24 + alias Waxx.Kanban.Board
25 +
26 + schema "card_templates" do
27 + field :name, :string
28 + field :snapshot, :map, default: %{}
29 +
30 + belongs_to :board, Board
31 + belongs_to :created_by, User
32 +
33 + timestamps(type: :utc_datetime)
34 + end
35 +
36 + def changeset(template, attrs) do
37 + template
38 + |> cast(attrs, [:board_id, :name, :snapshot, :created_by_id])
39 + |> validate_required([:board_id, :name])
40 + |> validate_length(:name, max: 120)
41 + |> unique_constraint([:board_id, :name])
42 + end
43 +end
modified lib/waxx_web/live/board_live/show.ex
+265 −3
@@ -34,6 +34,7 @@ defmodule WaxxWeb.BoardLive.Show do
34 34 |> assign(:editing_card, false)
35 35 |> assign(:card_edit_form, nil)
36 36 |> assign(:hide_label_text, Accounts.hide_label_text?(user, board_id))
37 + |> assign(:card_templates, Kanban.list_card_templates(board))
37 38 |> assign(:new_card_form, to_form(%{"title" => "", "description" => ""}, as: "card"))}
38 39 end
39 40 end
@@ -124,12 +125,31 @@ defmodule WaxxWeb.BoardLive.Show do
124 125 user = socket.assigns.current_scope.user
125 126 board = socket.assigns.board
126 127
127 − attrs =
128 + base_attrs =
128 129 params
129 130 |> Map.put("board_stage_id", stage_id)
130 131 |> Map.put("subboard_id", subboard_id)
132 + # The template_id select is hand-rolled (not a form field) so
133 + # it lives in the card params; pull it out before passing to
134 + # the schema, which doesn't have that field.
135 + |> Map.delete("template_id")
136 +
137 + result =
138 + case blank_to_nil(params["template_id"]) do
139 + nil ->
140 + Kanban.create_card(board, user, base_attrs)
141 +
142 + tpl_id ->
143 + case Kanban.get_card_template(tpl_id) do
144 + nil ->
145 + Kanban.create_card(board, user, base_attrs)
146 +
147 + tpl ->
148 + Kanban.create_card_from_template(board, user, tpl, base_attrs)
149 + end
150 + end
131 151
132 − case Kanban.create_card(board, user, attrs) do
152 + case result do
133 153 {:ok, _card} ->
134 154 {:noreply,
135 155 socket
@@ -393,6 +413,78 @@ defmodule WaxxWeb.BoardLive.Show do
393 413 end)
394 414 end
395 415
416 + def handle_event("add_note", %{"id" => card_id, "body" => body, "kind" => kind}, socket) do
417 + guard_edit(socket, fn ->
418 + card = Kanban.get_card(card_id)
419 + user = socket.assigns.current_scope.user
420 +
421 + cond do
422 + is_nil(card) or card.board_id != socket.assigns.board.id ->
423 + {:noreply, put_flash(socket, :error, "Card not found.")}
424 +
425 + true ->
426 + case Kanban.add_card_note(card, user, %{"body" => body, "kind" => kind}) do
427 + {:ok, _} -> {:noreply, socket}
428 + {:error, _} -> {:noreply, put_flash(socket, :error, "Couldn't add the note.")}
429 + end
430 + end
431 + end)
432 + end
433 +
434 + def handle_event("toggle_note", %{"id" => id}, socket) do
435 + guard_edit(socket, fn ->
436 + case find_note_on_open_card(socket, id) do
437 + nil ->
438 + {:noreply, socket}
439 +
440 + note ->
441 + {:ok, _} = Kanban.toggle_card_note_done(note)
442 + {:noreply, socket}
443 + end
444 + end)
445 + end
446 +
447 + def handle_event("delete_note", %{"id" => id}, socket) do
448 + guard_edit(socket, fn ->
449 + case find_note_on_open_card(socket, id) do
450 + nil ->
451 + {:noreply, socket}
452 +
453 + note ->
454 + {:ok, _} = Kanban.delete_card_note(note)
455 + {:noreply, socket}
456 + end
457 + end)
458 + end
459 +
460 + def handle_event("save_card_as_template", %{"id" => card_id, "name" => name}, socket) do
461 + guard_edit(socket, fn ->
462 + card = Kanban.get_card(card_id)
463 + user = socket.assigns.current_scope.user
464 + name = (name || "") |> String.trim()
465 +
466 + cond do
467 + is_nil(card) or card.board_id != socket.assigns.board.id ->
468 + {:noreply, put_flash(socket, :error, "Card not found.")}
469 +
470 + name == "" ->
471 + {:noreply, put_flash(socket, :error, "Template needs a name.")}
472 +
473 + true ->
474 + case Kanban.save_card_as_template(card, user, name) do
475 + {:ok, _} ->
476 + {:noreply,
477 + socket
478 + |> assign(:card_templates, Kanban.list_card_templates(socket.assigns.board))
479 + |> put_flash(:info, "Saved as template “#{name}”.")}
480 +
481 + {:error, _} ->
482 + {:noreply, put_flash(socket, :error, "Couldn't save template — name taken?")}
483 + end
484 + end
485 + end)
486 + end
487 +
396 488 def handle_event("toggle_label", %{"card-id" => card_id, "label-id" => label_id}, socket) do
397 489 guard_edit(socket, fn ->
398 490 card = Kanban.get_card(card_id)
@@ -548,6 +640,7 @@ defmodule WaxxWeb.BoardLive.Show do
548 640 role={@role}
549 641 new_card_form={@new_card_form}
550 642 board={@board}
643 + card_templates={@card_templates}
551 644 hide_label_text={@hide_label_text}
552 645 />
553 646
@@ -562,6 +655,7 @@ defmodule WaxxWeb.BoardLive.Show do
562 655 role={@role}
563 656 new_card_form={@new_card_form}
564 657 board={@board}
658 + card_templates={@card_templates}
565 659 hide_label_text={@hide_label_text}
566 660 />
567 661 <% end %>
@@ -750,6 +844,7 @@ defmodule WaxxWeb.BoardLive.Show do
750 844 attr :role, :string, required: true
751 845 attr :new_card_form, :any, required: true
752 846 attr :board, :map, required: true
847 + attr :card_templates, :list, default: []
753 848 attr :hide_label_text, :boolean, default: false
754 849
755 850 defp stage_cell(assigns) do
@@ -788,6 +883,16 @@ defmodule WaxxWeb.BoardLive.Show do
788 883 phx-mounted={JS.focus()}
789 884 />
790 885 <.input field={@new_card_form[:description]} type="text" label="Description" />
886 + <%= if @card_templates != [] do %>
887 + <label class="label-text text-xs mt-2">From template (optional)</label>
888 + <select
889 + name="card[template_id]"
890 + class="select select-sm select-bordered w-full"
891 + >
892 + <option value="">— blank —</option>
893 + <option :for={t <- @card_templates} value={t.id}>{t.name}</option>
894 + </select>
895 + <% end %>
791 896 <div class="flex gap-2 mt-2">
792 897 <.button class="btn btn-primary btn-sm">Create</.button>
793 898 <button type="button" phx-click="cancel_new_card" class="btn btn-ghost btn-sm">
@@ -847,6 +952,23 @@ defmodule WaxxWeb.BoardLive.Show do
847 952 {assignee_label(a)}
848 953 </span>
849 954 </div>
955 + <% note_counts = count_notes(card) %>
956 + <div
957 + :if={note_counts.notes > 0 or note_counts.todo_total > 0}
958 + class="flex gap-2 mt-2 text-[10px] opacity-60"
959 + >
960 + <span :if={note_counts.notes > 0} title="Notes" class="inline-flex items-center gap-0.5">
961 + <.icon name="hero-document-text-micro" class="size-3" /> {note_counts.notes}
962 + </span>
963 + <span
964 + :if={note_counts.todo_total > 0}
965 + title="Todos done/total"
966 + class="inline-flex items-center gap-0.5"
967 + >
968 + <.icon name="hero-check-circle-micro" class="size-3" />
969 + {note_counts.todo_done}/{note_counts.todo_total}
970 + </span>
971 + </div>
850 972 </article>
851 973 </div>
852 974 </div>
@@ -1047,6 +1169,80 @@ defmodule WaxxWeb.BoardLive.Show do
1047 1169 </div>
1048 1170 </div>
1049 1171
1172 + <div class="p-4 border-b border-base-300">
1173 + <h3 class="text-xs font-semibold uppercase tracking-wider opacity-60 mb-2">
1174 + Notes &amp; todos
1175 + </h3>
1176 +
1177 + <ul class="flex flex-col gap-1 mb-2">
1178 + <li :if={@card.notes == []} class="text-xs opacity-60 italic">
1179 + Nothing yet. Add a note or a todo below.
1180 + </li>
1181 + <li
1182 + :for={n <- @card.notes}
1183 + id={"note-#{n.id}"}
1184 + class="flex items-start gap-2 border-l-2 pl-2 py-1"
1185 + style={"border-color: #{note_stage_color(n, @board) || "transparent"}"}
1186 + >
1187 + <%= if n.kind == "todo" do %>
1188 + <input
1189 + type="checkbox"
1190 + checked={n.done}
1191 + disabled={not Kanban.can_edit?(@role)}
1192 + phx-click="toggle_note"
1193 + phx-value-id={n.id}
1194 + class="checkbox checkbox-xs mt-1"
1195 + />
1196 + <% else %>
1197 + <.icon name="hero-document-text-micro" class="size-4 mt-1 opacity-60 shrink-0" />
1198 + <% end %>
1199 + <div class="flex-1 min-w-0">
1200 + <p class={[
1201 + "text-sm whitespace-pre-wrap break-words",
1202 + n.kind == "todo" and n.done and "line-through opacity-60"
1203 + ]}>
1204 + {n.body}
1205 + </p>
1206 + <p class="text-[10px] opacity-60 mt-0.5">
1207 + {note_stage_name(n, @board)}
1208 + </p>
1209 + </div>
1210 + <button
1211 + :if={Kanban.can_edit?(@role)}
1212 + type="button"
1213 + phx-click="delete_note"
1214 + phx-value-id={n.id}
1215 + class="btn btn-ghost btn-xs text-error"
1216 + title="Delete"
1217 + >
1218 + <.icon name="hero-x-mark-micro" class="size-3" />
1219 + </button>
1220 + </li>
1221 + </ul>
1222 +
1223 + <.form
1224 + :if={Kanban.can_edit?(@role)}
1225 + for={%{}}
1226 + as={:note}
1227 + phx-submit="add_note"
1228 + phx-value-id={@card.id}
1229 + class="flex items-end gap-2"
1230 + >
1231 + <input
1232 + type="text"
1233 + name="body"
1234 + placeholder="Write a note or todo…"
1235 + required
1236 + class="input input-sm input-bordered flex-1"
1237 + />
1238 + <select name="kind" class="select select-sm select-bordered">
1239 + <option value="note">Note</option>
1240 + <option value="todo">Todo</option>
1241 + </select>
1242 + <.button class="btn btn-primary btn-sm">Add</.button>
1243 + </.form>
1244 + </div>
1245 +
1050 1246 <div class="p-4 border-b border-base-300">
1051 1247 <h3 class="text-xs font-semibold uppercase tracking-wider opacity-60 mb-2">
1052 1248 Move to (allowed transitions)
@@ -1073,7 +1269,26 @@ defmodule WaxxWeb.BoardLive.Show do
1073 1269 <% end %>
1074 1270 </div>
1075 1271
1076 − <div class="p-4 flex justify-end">
1272 + <div class="p-4 flex justify-between items-center gap-2">
1273 + <.form
1274 + :if={Kanban.can_edit?(@role)}
1275 + for={%{}}
1276 + as={:template}
1277 + phx-submit="save_card_as_template"
1278 + phx-value-id={@card.id}
1279 + class="flex items-center gap-2"
1280 + >
1281 + <input
1282 + type="text"
1283 + name="name"
1284 + placeholder="Template name"
1285 + required
1286 + class="input input-xs input-bordered w-36"
1287 + />
1288 + <.button class="btn btn-ghost btn-xs">
1289 + <.icon name="hero-bookmark-square-micro" class="size-4" /> Save as template
1290 + </.button>
1291 + </.form>
1077 1292 <button
1078 1293 :if={Kanban.can_edit?(@role)}
1079 1294 type="button"
@@ -1104,6 +1319,53 @@ defmodule WaxxWeb.BoardLive.Show do
1104 1319
1105 1320 defp label_style(_), do: ""
1106 1321
1322 + # Looks up a note id against the currently-expanded card. Keeps the
1323 + # match scoped to the card the user is actually viewing — a stray
1324 + # phx-value-id from a previous render can't reach a note on someone
1325 + # else's card.
1326 + defp find_note_on_open_card(socket, note_id) do
1327 + case socket.assigns.expanded_card do
1328 + %{notes: notes} -> Enum.find(notes, &(&1.id == note_id))
1329 + _ -> nil
1330 + end
1331 + end
1332 +
1333 + defp note_stage_color(%{board_stage_id: nil}, _board), do: nil
1334 +
1335 + defp note_stage_color(%{board_stage_id: stage_id}, board) do
1336 + case Enum.find(board.stages, &(&1.id == stage_id)) do
1337 + %{color: c} when is_binary(c) and c != "" -> c
1338 + _ -> nil
1339 + end
1340 + end
1341 +
1342 + defp note_stage_name(%{board_stage_id: nil}, _board), do: "(no stage)"
1343 +
1344 + defp note_stage_name(%{board_stage_id: stage_id}, board) do
1345 + case Enum.find(board.stages, &(&1.id == stage_id)) do
1346 + %{name: name} -> name
1347 + _ -> "(stage gone)"
1348 + end
1349 + end
1350 +
1351 + # Tally for the kanban tile chip row: how many freeform notes vs how
1352 + # many todos (done / total).
1353 + defp count_notes(card) do
1354 + Enum.reduce(card.notes || [], %{notes: 0, todo_total: 0, todo_done: 0}, fn n, acc ->
1355 + case n.kind do
1356 + "todo" ->
1357 + %{
1358 + acc
1359 + | todo_total: acc.todo_total + 1,
1360 + todo_done: acc.todo_done + if(n.done, do: 1, else: 0)
1361 + }
1362 +
1363 + _ ->
1364 + %{acc | notes: acc.notes + 1}
1365 + end
1366 + end)
1367 + end
1368 +
1107 1369 # Fields whose values should be surfaced on the kanban tile.
1108 1370 defp visible_field_values(card, board) do
1109 1371 by_id = Map.new(card.field_values, &{&1.board_field_id, &1.value})
added priv/repo/migrations/20260519143300_add_card_notes_and_templates.exs
+34 −0
@@ -0,0 +1,34 @@
1 +defmodule Waxx.Repo.Migrations.AddCardNotesAndTemplates do
2 + use Ecto.Migration
3 +
4 + def change do
5 + create table(:card_notes) do
6 + add :card_id, references(:cards, on_delete: :delete_all), null: false
7 + add :board_stage_id, references(:board_stages, on_delete: :nilify_all)
8 + add :body, :text, null: false
9 + add :kind, :string, null: false, default: "note"
10 + add :done, :boolean, null: false, default: false
11 + add :position, :integer, null: false, default: 0
12 + add :created_by_id, references(:users, on_delete: :nilify_all)
13 +
14 + timestamps(type: :utc_datetime)
15 + end
16 +
17 + create index(:card_notes, [:card_id])
18 + create index(:card_notes, [:board_stage_id])
19 +
20 + create constraint(:card_notes, :card_notes_kind_check, check: "kind in ('note','todo')")
21 +
22 + create table(:card_templates) do
23 + add :board_id, references(:boards, on_delete: :delete_all), null: false
24 + add :name, :string, null: false
25 + add :snapshot, :map, null: false, default: %{}
26 + add :created_by_id, references(:users, on_delete: :nilify_all)
27 +
28 + timestamps(type: :utc_datetime)
29 + end
30 +
31 + create index(:card_templates, [:board_id])
32 + create unique_index(:card_templates, [:board_id, :name])
33 + end
34 +end
modified test/waxx/kanban_test.exs
+81 −0
@@ -282,6 +282,87 @@ defmodule Waxx.KanbanTest do
282 282 end
283 283 end
284 284
285 + describe "card notes + todos" do
286 + test "add_card_note stamps the card's current stage on the note" do
287 + user = AccountsFixtures.user_fixture()
288 + template = build_template_with_two_stages_and_transition(user)
289 + {:ok, board} = Kanban.create_board_from_template(user, template, %{"name" => "B"})
290 + [from_stage, _done] = board.stages
291 + {:ok, card} = Kanban.create_card(board, user, %{"title" => "T"})
292 +
293 + {:ok, note} = Kanban.add_card_note(card, user, %{"body" => "hello", "kind" => "note"})
294 + assert note.board_stage_id == from_stage.id
295 + assert note.kind == "note"
296 + assert note.done == false
297 + end
298 +
299 + test "toggle_card_note_done flips and back" do
300 + user = AccountsFixtures.user_fixture()
301 + template = build_template_with_two_stages_and_transition(user)
302 + {:ok, board} = Kanban.create_board_from_template(user, template, %{"name" => "B"})
303 + {:ok, card} = Kanban.create_card(board, user, %{"title" => "T"})
304 +
305 + {:ok, todo} = Kanban.add_card_note(card, user, %{"body" => "x", "kind" => "todo"})
306 + assert {:ok, %{done: true}} = Kanban.toggle_card_note_done(todo)
307 +
308 + assert {:ok, %{done: false}} =
309 + Kanban.toggle_card_note_done(%{todo | done: true})
310 + end
311 + end
312 +
313 + describe "card templates" do
314 + test "save_card_as_template snapshots labels by name + field values" do
315 + user = AccountsFixtures.user_fixture()
316 + template = build_template_with_two_stages_and_transition(user)
317 + {:ok, _} = Waxx.Workflows.add_label(template, %{"name" => "bug"})
318 + {:ok, _} = Waxx.Workflows.add_field(template, %{"name" => "due", "kind" => "date"})
319 +
320 + {:ok, board} = Kanban.create_board_from_template(user, template, %{"name" => "B"})
321 + board = Kanban.get_board_with_workflow!(board.id, user)
322 + [bug_label] = board.labels
323 + [due_field] = board.fields
324 +
325 + {:ok, card} =
326 + Kanban.create_card(board, user, %{"title" => "Repro", "description" => "steps"})
327 +
328 + {:ok, _} = Kanban.toggle_card_label(card, bug_label)
329 + {:ok, _} = Kanban.set_card_field_value(card, due_field, "2026-05-20")
330 + {:ok, _} = Kanban.add_card_note(card, user, %{"body" => "ping qa", "kind" => "todo"})
331 +
332 + card = Kanban.get_card(card.id)
333 + {:ok, tpl} = Kanban.save_card_as_template(card, user, "bug-skeleton")
334 +
335 + assert tpl.snapshot["title"] == "Repro"
336 + assert tpl.snapshot["label_names"] == ["bug"]
337 + assert tpl.snapshot["field_values"]["due"] == "2026-05-20"
338 + assert [%{"body" => "ping qa", "kind" => "todo"}] = tpl.snapshot["notes"]
339 + end
340 +
341 + test "create_card_from_template resolves labels + fields by name" do
342 + user = AccountsFixtures.user_fixture()
343 + template = build_template_with_two_stages_and_transition(user)
344 + {:ok, _} = Waxx.Workflows.add_label(template, %{"name" => "bug"})
345 + {:ok, _} = Waxx.Workflows.add_field(template, %{"name" => "due", "kind" => "date"})
346 +
347 + {:ok, board} = Kanban.create_board_from_template(user, template, %{"name" => "B"})
348 + board = Kanban.get_board_with_workflow!(board.id, user)
349 + [bug_label] = board.labels
350 + [due_field] = board.fields
351 +
352 + {:ok, source} = Kanban.create_card(board, user, %{"title" => "Source"})
353 + {:ok, _} = Kanban.toggle_card_label(source, bug_label)
354 + {:ok, _} = Kanban.set_card_field_value(source, due_field, "2026-06-01")
355 +
356 + source = Kanban.get_card(source.id)
357 + {:ok, tpl} = Kanban.save_card_as_template(source, user, "skel")
358 + {:ok, fresh} = Kanban.create_card_from_template(board, user, tpl, %{"title" => "New"})
359 +
360 + assert fresh.title == "New"
361 + assert [%{name: "bug"}] = fresh.labels
362 + assert [%{value: "2026-06-01"}] = fresh.field_values
363 + end
364 + end
365 +
285 366 describe "subboards" do
286 367 test "cards start in the default row and can be moved into a subboard" do
287 368 user = AccountsFixtures.user_fixture()

Parents: 7075226