neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
board-label-editor
dd6022a · gmorell · 2026-06-18 20:44
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