board-label-editor

dd6022a · gmorell · 2026-06-18 20:44

17 files +381 -0

Files changed

modified lib/waxx/kanban.ex
+65 −0
@@ -639,6 +639,71 @@ defmodule Waxx.Kanban do
639 639 Repo.one(from(sb in Subboard, where: sb.id == ^subboard_id, select: sb.name)) || "Default"
640 640 end
641 641
642 + ## Board labels --------------------------------------------------------
643 +
644 + @doc "Lists a board's labels alphabetically."
645 + def list_board_labels(%Board{id: board_id}) do
646 + Repo.all(from(l in BoardLabel, where: l.board_id == ^board_id, order_by: [asc: l.name]))
647 + end
648 +
649 + @doc """
650 + Adds a label directly to a board, independent of the board's template
651 + (boards own their label list and may drift from the template they were
652 + cloned from). Broadcasts `:workflow_changed` so open kanban views
653 + refresh their chips.
654 + """
655 + def create_board_label(%Board{} = board, attrs) do
656 + attrs =
657 + attrs
658 + |> stringify_keys()
659 + |> Map.put("board_id", board.id)
660 +
661 + case %BoardLabel{} |> BoardLabel.changeset(attrs) |> Repo.insert() do
662 + {:ok, _} = result ->
663 + broadcast_workflow_changed(board.id)
664 + result
665 +
666 + other ->
667 + other
668 + end
669 + end
670 +
671 + @doc "Renames/recolors a board label. Broadcasts on success."
672 + def update_board_label(%BoardLabel{} = label, attrs) do
673 + attrs = attrs |> stringify_keys() |> Map.delete("board_id")
674 +
675 + case label |> BoardLabel.changeset(attrs) |> Repo.update() do
676 + {:ok, _} = result ->
677 + broadcast_workflow_changed(label.board_id)
678 + result
679 +
680 + other ->
681 + other
682 + end
683 + end
684 +
685 + @doc """
686 + Deletes a board label. Refuses with `{:error, :in_use}` while any card
687 + still references it (same rule the template-removal propagation
688 + enforces). Broadcasts on success.
689 + """
690 + def delete_board_label(%BoardLabel{} = label) do
691 + in_use? = Repo.exists?(from(cl in CardLabel, where: cl.board_label_id == ^label.id))
692 +
693 + if in_use? do
694 + {:error, :in_use}
695 + else
696 + case Repo.delete(label) do
697 + {:ok, _} = result ->
698 + broadcast_workflow_changed(label.board_id)
699 + result
700 +
701 + other ->
702 + other
703 + end
704 + end
705 + end
706 +
642 707 @doc "Board with workflow graph, labels, custom fields, and members preloaded."
643 708 def get_board_with_workflow!(board_id, %User{} = user) do
644 709 board = get_board_for_user(board_id, user) || raise Ecto.NoResultsError, queryable: Board
modified lib/waxx_web/api/v1/board_json.ex
+3 −0
@@ -129,6 +129,9 @@ defmodule WaxxWeb.Api.V1.BoardJSON do
129 129
130 130 def subboard_response(%Subboard{} = sb), do: %{subboard: subboard(sb)}
131 131
132 + @doc "Single-label envelope used by the board-label mutation endpoints."
133 + def board_label_response(%BoardLabel{} = l), do: %{label: label(l)}
134 +
132 135 ## Cards ---------------------------------------------------------------
133 136
134 137 def cards(cards) when is_list(cards) do
added lib/waxx_web/api/v1/board_label_controller.ex
+72 −0
@@ -0,0 +1,72 @@
1 +defmodule WaxxWeb.Api.V1.BoardLabelController do
2 + @moduledoc """
3 + Board-level label management. Owners only. Lets a board grow its own
4 + labels beyond the set cloned from its template.
5 +
6 + POST /api/v1/boards/:board_id/labels {name, color}
7 + PATCH /api/v1/board_labels/:id {name, color}
8 + DELETE /api/v1/board_labels/:id
9 +
10 + Deletion is refused (422 `in_use`) while any card still wears the
11 + label — same rule as template-label removal propagation.
12 + """
13 +
14 + use WaxxWeb, :controller
15 +
16 + alias Waxx.{Kanban, Repo}
17 + alias Waxx.Kanban.BoardLabel
18 + alias WaxxWeb.Api.V1.BoardJSON
19 +
20 + action_fallback WaxxWeb.Api.FallbackController
21 +
22 + def create(conn, %{"board_id" => board_id} = params) do
23 + user = conn.assigns.current_scope.user
24 +
25 + with {:ok, board} <- fetch_owner_board(board_id, user),
26 + attrs <- Map.take(params, ["name", "color"]),
27 + {:ok, label} <- Kanban.create_board_label(board, attrs) do
28 + conn
29 + |> put_status(:created)
30 + |> json(BoardJSON.board_label_response(label))
31 + end
32 + end
33 +
34 + def update(conn, %{"id" => id} = params) do
35 + user = conn.assigns.current_scope.user
36 +
37 + with %BoardLabel{board_id: board_id} = label <-
38 + Repo.get(BoardLabel, id) || {:error, :not_found},
39 + {:ok, _board} <- fetch_owner_board(board_id, user),
40 + attrs <- Map.take(params, ["name", "color"]),
41 + {:ok, updated} <- Kanban.update_board_label(label, attrs) do
42 + json(conn, BoardJSON.board_label_response(updated))
43 + else
44 + {:error, _} = err -> err
45 + end
46 + end
47 +
48 + def delete(conn, %{"id" => id}) do
49 + user = conn.assigns.current_scope.user
50 +
51 + with %BoardLabel{board_id: board_id} = label <-
52 + Repo.get(BoardLabel, id) || {:error, :not_found},
53 + {:ok, _board} <- fetch_owner_board(board_id, user),
54 + {:ok, _} <- Kanban.delete_board_label(label) do
55 + send_resp(conn, :no_content, "")
56 + else
57 + {:error, _} = err -> err
58 + end
59 + end
60 +
61 + defp fetch_owner_board(board_id, user) do
62 + case Kanban.get_board_for_user(board_id, user) do
63 + nil ->
64 + {:error, :not_found}
65 +
66 + board ->
67 + if Kanban.can_manage?(Kanban.role_for(board.id, user)),
68 + do: {:ok, board},
69 + else: {:error, :forbidden}
70 + end
71 + end
72 +end
modified lib/waxx_web/live/board_live/settings.ex
+106 −0
@@ -29,6 +29,8 @@ defmodule WaxxWeb.BoardLive.Settings do
29 29 |> assign(:board_form, to_form(Kanban.change_board(board)))
30 30 |> assign(:subboards, Kanban.list_subboards(board))
31 31 |> assign(:subboard_form, to_form(%{"name" => ""}, as: "subboard"))
32 + |> assign(:labels, Kanban.list_board_labels(board))
33 + |> assign(:label_form, blank_label_form())
32 34
33 35 {:ok, socket}
34 36 end
@@ -130,6 +132,50 @@ defmodule WaxxWeb.BoardLive.Settings do
130 132 end
131 133 end
132 134
135 + def handle_event("create_label", %{"label" => params}, socket) do
136 + if Kanban.can_manage?(socket.assigns.role) do
137 + case Kanban.create_board_label(socket.assigns.board, params) do
138 + {:ok, _} ->
139 + {:noreply,
140 + socket
141 + |> assign(:labels, Kanban.list_board_labels(socket.assigns.board))
142 + |> assign(:label_form, blank_label_form())
143 + |> put_flash(:info, "Label added.")}
144 +
145 + {:error, cs} ->
146 + {:noreply, put_flash(socket, :error, "Could not add label: #{format_errors(cs)}")}
147 + end
148 + else
149 + {:noreply, put_flash(socket, :error, "Only the owner can add labels.")}
150 + end
151 + end
152 +
153 + def handle_event("delete_label", %{"id" => id}, socket) do
154 + if Kanban.can_manage?(socket.assigns.role) do
155 + label = Enum.find(socket.assigns.labels, &(&1.id == id))
156 +
157 + case label && Kanban.delete_board_label(label) do
158 + nil ->
159 + {:noreply, socket}
160 +
161 + {:ok, _} ->
162 + {:noreply,
163 + socket
164 + |> assign(:labels, Kanban.list_board_labels(socket.assigns.board))
165 + |> put_flash(:info, "Label deleted.")}
166 +
167 + {:error, :in_use} ->
168 + {:noreply,
169 + put_flash(socket, :error, "That label is still attached to cards — remove it there first.")}
170 +
171 + {:error, _} ->
172 + {:noreply, put_flash(socket, :error, "Could not delete label.")}
173 + end
174 + else
175 + {:noreply, put_flash(socket, :error, "Only the owner can delete labels.")}
176 + end
177 + end
178 +
133 179 def handle_event("create_subboard", %{"subboard" => params}, socket) do
134 180 if Kanban.can_manage?(socket.assigns.role) do
135 181 case Kanban.create_subboard(socket.assigns.board, params) do
@@ -167,6 +213,14 @@ defmodule WaxxWeb.BoardLive.Settings do
167 213 end
168 214 end
169 215
216 + defp blank_label_form do
217 + to_form(%{"name" => "", "color" => ""}, as: "label")
218 + end
219 +
220 + defp format_errors(%Ecto.Changeset{errors: errors}) do
221 + Enum.map_join(errors, ", ", fn {field, {msg, _}} -> "#{field} #{msg}" end)
222 + end
223 +
170 224 @impl true
171 225 def render(assigns) do
172 226 ~H"""
@@ -312,6 +366,58 @@ defmodule WaxxWeb.BoardLive.Settings do
312 366 </ul>
313 367 </section>
314 368
369 + <section>
370 + <h2 class="text-lg font-semibold mb-2">Labels</h2>
371 + <p class="text-sm opacity-70 mb-3">
372 + Labels start as a copy of the board's template, but the board
373 + owns its list — add more here without touching the template.
374 + A label still attached to cards can't be deleted.
375 + </p>
376 +
377 + <ul class="flex flex-wrap gap-2 mb-3">
378 + <li :if={@labels == []} class="text-sm opacity-60">
379 + No labels yet.
380 + </li>
381 + <li
382 + :for={lab <- @labels}
383 + id={"board-label-#{lab.id}"}
384 + class="border border-base-300 rounded-box pl-2 pr-1 py-1 bg-base-200 flex items-center gap-2"
385 + >
386 + <span
387 + :if={lab.color && lab.color != ""}
388 + class="size-3 rounded-full border border-base-content/20"
389 + style={"background: #{lab.color}"}
390 + />
391 + <span class="text-sm">{lab.name}</span>
392 + <button
393 + :if={Kanban.can_manage?(@role)}
394 + type="button"
395 + phx-click="delete_label"
396 + phx-value-id={lab.id}
397 + data-confirm="Remove this label from the board?"
398 + class="btn btn-ghost btn-xs text-error"
399 + aria-label="Delete label"
400 + >
401 + <.icon name="hero-x-mark-micro" class="size-3" />
402 + </button>
403 + </li>
404 + </ul>
405 +
406 + <.form
407 + :if={Kanban.can_manage?(@role)}
408 + for={@label_form}
409 + id="board-label-form"
410 + phx-submit="create_label"
411 + class="flex flex-col gap-3"
412 + >
413 + <div class="flex items-end gap-2">
414 + <.input field={@label_form[:name]} type="text" label="New label name" required />
415 + <.button class="btn btn-primary">Add label</.button>
416 + </div>
417 + <.color_picker field={@label_form[:color]} label="Color (optional)" />
418 + </.form>
419 + </section>
420 +
315 421 <section>
316 422 <h2 class="text-lg font-semibold mb-2">Subboards</h2>
317 423 <p class="text-sm opacity-70 mb-3">
modified lib/waxx_web/router.ex
+4 −0
@@ -154,6 +154,10 @@ defmodule WaxxWeb.Router do
154 154 patch "/subboards/:id", SubboardController, :update
155 155 delete "/subboards/:id", SubboardController, :delete
156 156
157 + post "/boards/:board_id/labels", BoardLabelController, :create
158 + patch "/board_labels/:id", BoardLabelController, :update
159 + delete "/board_labels/:id", BoardLabelController, :delete
160 +
157 161 # Workflow templates ---------------------------------------------
158 162 get "/workflow_templates", TemplateController, :index
159 163 post "/workflow_templates", TemplateController, :create
added test/waxx_web/api/v1/board_labels_test.exs
+131 −0
@@ -0,0 +1,131 @@
1 +defmodule WaxxWeb.Api.V1.BoardLabelsTest do
2 + use WaxxWeb.ConnCase, async: true
3 +
4 + import Waxx.AccountsFixtures
5 + import Waxx.KanbanFixtures
6 +
7 + alias Waxx.{Kanban, Repo}
8 + alias Waxx.Kanban.BoardLabel
9 +
10 + defp setup_owner do
11 + user = confirmed_user_fixture()
12 + board = board_fixture(user)
13 + %{user: user, board: board, token: api_token_fixture(user)}
14 + end
15 +
16 + defp setup_editor do
17 + owner = confirmed_user_fixture()
18 + editor = confirmed_user_fixture()
19 + board = board_fixture(owner)
20 + {:ok, _} = Kanban.add_member(board, editor, "editor")
21 + %{owner: owner, board: board, token: api_token_fixture(editor)}
22 + end
23 +
24 + defp auth(conn, token), do: put_req_header(conn, "authorization", "Bearer " <> token)
25 +
26 + describe "POST /api/v1/boards/:board_id/labels" do
27 + test "owner can add a label", %{conn: conn} do
28 + %{token: token, board: board} = setup_owner()
29 +
30 + conn =
31 + conn
32 + |> auth(token)
33 + |> post(~p"/api/v1/boards/#{board.id}/labels", %{name: "urgent", color: "#ff0000"})
34 +
35 + assert %{"label" => label} = json_response(conn, 201)
36 + assert label["name"] == "urgent"
37 + assert label["color"] == "#ff0000"
38 + assert Repo.get_by(BoardLabel, board_id: board.id, name: "urgent")
39 + end
40 +
41 + test "duplicate name on the same board is a 422", %{conn: conn} do
42 + %{token: token, board: board} = setup_owner()
43 + board_label_fixture(board, name: "urgent")
44 +
45 + conn =
46 + conn
47 + |> auth(token)
48 + |> post(~p"/api/v1/boards/#{board.id}/labels", %{name: "urgent"})
49 +
50 + assert json_response(conn, 422)["error"]["code"] == "validation_failed"
51 + end
52 +
53 + test "non-owner gets 403", %{conn: conn} do
54 + %{token: token, board: board} = setup_editor()
55 +
56 + conn =
57 + conn
58 + |> auth(token)
59 + |> post(~p"/api/v1/boards/#{board.id}/labels", %{name: "nope"})
60 +
61 + assert json_response(conn, 403)
62 + end
63 + end
64 +
65 + describe "PATCH /api/v1/board_labels/:id" do
66 + test "owner can rename and recolor", %{conn: conn} do
67 + %{token: token, board: board} = setup_owner()
68 + label = board_label_fixture(board, name: "urgent")
69 +
70 + conn =
71 + conn
72 + |> auth(token)
73 + |> patch(~p"/api/v1/board_labels/#{label.id}", %{name: "blocked", color: "#00ff00"})
74 +
75 + assert %{"label" => l} = json_response(conn, 200)
76 + assert l["name"] == "blocked"
77 + assert l["color"] == "#00ff00"
78 + end
79 +
80 + test "non-owner gets 403", %{conn: conn} do
81 + %{token: token, board: board} = setup_editor()
82 + label = board_label_fixture(board)
83 +
84 + conn = conn |> auth(token) |> patch(~p"/api/v1/board_labels/#{label.id}", %{name: "x"})
85 + assert json_response(conn, 403)
86 + end
87 + end
88 +
89 + describe "DELETE /api/v1/board_labels/:id" do
90 + test "owner can delete an unused label", %{conn: conn} do
91 + %{token: token, board: board} = setup_owner()
92 + label = board_label_fixture(board)
93 +
94 + conn = conn |> auth(token) |> delete(~p"/api/v1/board_labels/#{label.id}")
95 + assert response(conn, 204) == ""
96 + refute Repo.get(BoardLabel, label.id)
97 + end
98 +
99 + test "refuses to delete a label still attached to a card", %{conn: conn} do
100 + %{token: token, board: board, user: user} = setup_owner()
101 + label = board_label_fixture(board)
102 + card = card_fixture(board, user)
103 + {:ok, _} = Kanban.toggle_card_label(card, label)
104 +
105 + conn = conn |> auth(token) |> delete(~p"/api/v1/board_labels/#{label.id}")
106 + assert json_response(conn, 422)["error"]["code"] == "in_use"
107 + assert Repo.get(BoardLabel, label.id)
108 + end
109 +
110 + test "non-owner gets 403", %{conn: conn} do
111 + %{token: token, board: board} = setup_editor()
112 + label = board_label_fixture(board)
113 +
114 + conn = conn |> auth(token) |> delete(~p"/api/v1/board_labels/#{label.id}")
115 + assert json_response(conn, 403)
116 + end
117 +
118 + test "non-member gets 404", %{conn: conn} do
119 + %{board: board} = setup_owner()
120 + label = board_label_fixture(board)
121 + stranger = confirmed_user_fixture()
122 +
123 + conn =
124 + conn
125 + |> auth(api_token_fixture(stranger))
126 + |> delete(~p"/api/v1/board_labels/#{label.id}")
127 +
128 + assert json_response(conn, 404)
129 + end
130 + end
131 +end

Parents: a268c83