neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
hmm
66113d8 · gmorell · 2026-05-21 04:43
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 & 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