subboard scoped labels

0b2ca62 · gmorell · 2026-06-18 22:22

26 files +277 -34

Files changed

modified lib/waxx/kanban.ex
+72 −15
@@ -641,9 +641,15 @@ defmodule Waxx.Kanban do
641 641
642 642 ## Board labels --------------------------------------------------------
643 643
644 − @doc "Lists a board's labels alphabetically."
644 + @doc "Lists a board's labels alphabetically, with their subboard scope preloaded."
645 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]))
646 + Repo.all(
647 + from(l in BoardLabel,
648 + where: l.board_id == ^board_id,
649 + order_by: [asc: l.name],
650 + preload: [:subboards]
651 + )
652 + )
647 653 end
648 654
649 655 @doc """
@@ -651,28 +657,47 @@ defmodule Waxx.Kanban do
651 657 (boards own their label list and may drift from the template they were
652 658 cloned from). Broadcasts `:workflow_changed` so open kanban views
653 659 refresh their chips.
660 +
661 + `attrs` may carry `subboard_ids` to restrict the label to one or more of
662 + the board's subboards (omit or pass an empty list for a board-wide label).
663 +
664 + Re-adding a label whose name already exists on the board is treated as an
665 + edit: its color and subboard scope are updated in place rather than
666 + failing the unique constraint. Returns `{:ok, :created | :updated, label}`
667 + on success, or `{:error, changeset}`.
654 668 """
655 669 def create_board_label(%Board{} = board, attrs) do
656 − attrs =
657 − attrs
658 − |> stringify_keys()
659 − |> Map.put("board_id", board.id)
670 + attrs = stringify_keys(attrs)
671 + name = attrs["name"]
660 672
661 − case %BoardLabel{} |> BoardLabel.changeset(attrs) |> Repo.insert() do
662 − {:ok, _} = result ->
663 − broadcast_workflow_changed(board.id)
664 − result
673 + case name && Repo.get_by(BoardLabel, board_id: board.id, name: name) do
674 + %BoardLabel{} = existing ->
675 + existing
676 + |> Repo.preload(:subboards)
677 + |> build_board_label_changeset(board, attrs)
678 + |> Repo.update()
679 + |> tag_board_label_result(board.id, :updated)
665 680
666 − other ->
667 − other
681 + _ ->
682 + %BoardLabel{}
683 + |> build_board_label_changeset(board, attrs)
684 + |> Repo.insert()
685 + |> tag_board_label_result(board.id, :created)
668 686 end
669 687 end
670 688
671 − @doc "Renames/recolors a board label. Broadcasts on success."
689 + @doc """
690 + Renames/recolors a board label and, when `subboard_ids` is present in
691 + `attrs`, replaces its subboard scope. Broadcasts on success.
692 + """
672 693 def update_board_label(%BoardLabel{} = label, attrs) do
673 694 attrs = attrs |> stringify_keys() |> Map.delete("board_id")
695 + board = %Board{id: label.board_id}
674 696
675 − case label |> BoardLabel.changeset(attrs) |> Repo.update() do
697 + case label
698 + |> Repo.preload(:subboards)
699 + |> build_board_label_changeset(board, attrs)
700 + |> Repo.update() do
676 701 {:ok, _} = result ->
677 702 broadcast_workflow_changed(label.board_id)
678 703 result
@@ -682,6 +707,38 @@ defmodule Waxx.Kanban do
682 707 end
683 708 end
684 709
710 + # Builds a BoardLabel changeset, pinning board_id and (only when
711 + # `subboard_ids` was supplied) replacing the subboard scope via put_assoc.
712 + defp build_board_label_changeset(label, %Board{} = board, attrs) do
713 + changeset =
714 + label
715 + |> BoardLabel.changeset(Map.put(attrs, "board_id", board.id))
716 +
717 + case Map.fetch(attrs, "subboard_ids") do
718 + {:ok, ids} -> Ecto.Changeset.put_assoc(changeset, :subboards, scoped_subboards(board, ids))
719 + :error -> changeset
720 + end
721 + end
722 +
723 + # Resolves submitted subboard ids to the board's own Subboard rows,
724 + # dropping blanks and anything that doesn't belong to this board.
725 + defp scoped_subboards(%Board{id: board_id}, ids) do
726 + ids = ids |> List.wrap() |> Enum.reject(&(&1 in [nil, ""]))
727 +
728 + if ids == [] do
729 + []
730 + else
731 + Repo.all(from(s in Subboard, where: s.board_id == ^board_id and s.id in ^ids))
732 + end
733 + end
734 +
735 + defp tag_board_label_result({:ok, label}, board_id, tag) do
736 + broadcast_workflow_changed(board_id)
737 + {:ok, tag, label}
738 + end
739 +
740 + defp tag_board_label_result(other, _board_id, _tag), do: other
741 +
685 742 @doc """
686 743 Deletes a board label. Refuses with `{:error, :in_use}` while any card
687 744 still references it (same rule the template-removal propagation
@@ -711,7 +768,7 @@ defmodule Waxx.Kanban do
711 768 Repo.preload(board,
712 769 stages: from(s in BoardStage, order_by: [asc: s.position]),
713 770 transitions: [:from_stage, :to_stage],
714 − labels: from(l in BoardLabel, order_by: [asc: l.name]),
771 + labels: {from(l in BoardLabel, order_by: [asc: l.name]), [:subboards]},
715 772 fields: from(f in BoardField, order_by: [asc: f.position, asc: f.id]),
716 773 subboards: from(sb in Subboard, order_by: [asc: sb.position, asc: sb.inserted_at]),
717 774 memberships: [:user]
modified lib/waxx/kanban/board_label.ex
+18 −1
@@ -2,7 +2,7 @@ defmodule Waxx.Kanban.BoardLabel do
2 2 use Waxx.Schema
3 3 import Ecto.Changeset
4 4
5 − alias Waxx.Kanban.Board
5 + alias Waxx.Kanban.{Board, Subboard}
6 6
7 7 schema "board_labels" do
8 8 field :name, :string
@@ -10,6 +10,13 @@ defmodule Waxx.Kanban.BoardLabel do
10 10
11 11 belongs_to :board, Board
12 12
13 + # Subboards this label is restricted to. Empty = board-wide (the label
14 + # applies to every card regardless of subboard). Managed via put_assoc
15 + # in Waxx.Kanban; `on_replace: :delete` clears the old scope on update.
16 + many_to_many :subboards, Subboard,
17 + join_through: Waxx.Kanban.BoardLabelSubboard,
18 + on_replace: :delete
19 +
13 20 timestamps(type: :utc_datetime)
14 21 end
15 22
@@ -21,4 +28,14 @@ defmodule Waxx.Kanban.BoardLabel do
21 28 |> validate_length(:color, max: 32)
22 29 |> unique_constraint([:board_id, :name])
23 30 end
31 +
32 + @doc """
33 + True when this label may be applied to a card sitting in `subboard_id`.
34 + A board-wide label (no subboard scope) applies everywhere; a scoped label
35 + only applies inside its subboards. Requires `:subboards` to be loaded.
36 + """
37 + def applies_to_subboard?(%__MODULE__{subboards: subboards}, subboard_id)
38 + when is_list(subboards) do
39 + subboards == [] or Enum.any?(subboards, &(&1.id == subboard_id))
40 + end
24 41 end
added lib/waxx/kanban/board_label_subboard.ex
+18 −0
@@ -0,0 +1,18 @@
1 +defmodule Waxx.Kanban.BoardLabelSubboard do
2 + @moduledoc """
3 + Join row scoping a `BoardLabel` to a `Subboard`. A label with no rows here
4 + is board-wide; rows restrict it to the listed subboards. Used as the
5 + `join_through` schema for `BoardLabel.subboards` so the join's UUID primary
6 + key and timestamps are autogenerated like every other table.
7 + """
8 + use Waxx.Schema
9 +
10 + alias Waxx.Kanban.{BoardLabel, Subboard}
11 +
12 + schema "board_label_subboards" do
13 + belongs_to :board_label, BoardLabel
14 + belongs_to :subboard, Subboard
15 +
16 + timestamps(type: :utc_datetime)
17 + end
18 +end
modified lib/waxx_web/api/v1/board_json.ex
+8 −1
@@ -109,9 +109,16 @@ defmodule WaxxWeb.Api.V1.BoardJSON do
109 109 end
110 110
111 111 defp label(%BoardLabel{} = l) do
112 − %{id: l.id, name: l.name, color: l.color}
112 + %{id: l.id, name: l.name, color: l.color, subboard_ids: label_subboard_ids(l)}
113 113 end
114 114
115 + # Board-wide labels report `[]`. Falls back to `[]` when the association
116 + # wasn't preloaded so the field is always present and well-typed.
117 + defp label_subboard_ids(%BoardLabel{subboards: subboards}) when is_list(subboards),
118 + do: Enum.map(subboards, & &1.id)
119 +
120 + defp label_subboard_ids(_), do: []
121 +
115 122 defp field(%BoardField{} = f) do
116 123 %{
117 124 id: f.id,
modified lib/waxx_web/api/v1/board_label_controller.ex
+10 −6
@@ -3,10 +3,14 @@ defmodule WaxxWeb.Api.V1.BoardLabelController do
3 3 Board-level label management. Owners only. Lets a board grow its own
4 4 labels beyond the set cloned from its template.
5 5
6 − POST /api/v1/boards/:board_id/labels {name, color}
7 − PATCH /api/v1/board_labels/:id {name, color}
6 + POST /api/v1/boards/:board_id/labels {name, color, subboard_ids}
7 + PATCH /api/v1/board_labels/:id {name, color, subboard_ids}
8 8 DELETE /api/v1/board_labels/:id
9 9
10 + `subboard_ids` (optional) restricts the label to those subboards; omit or
11 + pass `[]` for a board-wide label. POSTing a name that already exists on
12 + the board updates that label in place (200) instead of failing.
13 +
10 14 Deletion is refused (422 `in_use`) while any card still wears the
11 15 label — same rule as template-label removal propagation.
12 16 """
@@ -23,10 +27,10 @@ defmodule WaxxWeb.Api.V1.BoardLabelController do
23 27 user = conn.assigns.current_scope.user
24 28
25 29 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
30 + attrs <- Map.take(params, ["name", "color", "subboard_ids"]),
31 + {:ok, tag, label} <- Kanban.create_board_label(board, attrs) do
28 32 conn
29 − |> put_status(:created)
33 + |> put_status(if(tag == :created, do: :created, else: :ok))
30 34 |> json(BoardJSON.board_label_response(label))
31 35 end
32 36 end
@@ -37,7 +41,7 @@ defmodule WaxxWeb.Api.V1.BoardLabelController do
37 41 with %BoardLabel{board_id: board_id} = label <-
38 42 Repo.get(BoardLabel, id) || {:error, :not_found},
39 43 {:ok, _board} <- fetch_owner_board(board_id, user),
40 − attrs <- Map.take(params, ["name", "color"]),
44 + attrs <- Map.take(params, ["name", "color", "subboard_ids"]),
41 45 {:ok, updated} <- Kanban.update_board_label(label, attrs) do
42 46 json(conn, BoardJSON.board_label_response(updated))
43 47 else
modified lib/waxx_web/live/board_live/settings.ex
+34 −4
@@ -135,12 +135,14 @@ defmodule WaxxWeb.BoardLive.Settings do
135 135 def handle_event("create_label", %{"label" => params}, socket) do
136 136 if Kanban.can_manage?(socket.assigns.role) do
137 137 case Kanban.create_board_label(socket.assigns.board, params) do
138 − {:ok, _} ->
138 + {:ok, tag, _} ->
139 + flash = if tag == :updated, do: "Label updated.", else: "Label added."
140 +
139 141 {:noreply,
140 142 socket
141 143 |> assign(:labels, Kanban.list_board_labels(socket.assigns.board))
142 144 |> assign(:label_form, blank_label_form())
143 − |> put_flash(:info, "Label added.")}
145 + |> put_flash(:info, flash)}
144 146
145 147 {:error, cs} ->
146 148 {:noreply, put_flash(socket, :error, "Could not add label: #{format_errors(cs)}")}
@@ -166,7 +168,11 @@ defmodule WaxxWeb.BoardLive.Settings do
166 168
167 169 {:error, :in_use} ->
168 170 {:noreply,
169 − put_flash(socket, :error, "That label is still attached to cards — remove it there first.")}
171 + put_flash(
172 + socket,
173 + :error,
174 + "That label is still attached to cards — remove it there first."
175 + )}
170 176
171 177 {:error, _} ->
172 178 {:noreply, put_flash(socket, :error, "Could not delete label.")}
@@ -214,7 +220,7 @@ defmodule WaxxWeb.BoardLive.Settings do
214 220 end
215 221
216 222 defp blank_label_form do
217 − to_form(%{"name" => "", "color" => ""}, as: "label")
223 + to_form(%{"name" => "", "color" => "", "subboard_ids" => []}, as: "label")
218 224 end
219 225
220 226 defp format_errors(%Ecto.Changeset{errors: errors}) do
@@ -389,6 +395,13 @@ defmodule WaxxWeb.BoardLive.Settings do
389 395 style={"background: #{lab.color}"}
390 396 />
391 397 <span class="text-sm">{lab.name}</span>
398 + <span
399 + :if={lab.subboards != []}
400 + class="text-[10px] opacity-60"
401 + title="Only applies to cards in these subboards"
402 + >
403 + {Enum.map_join(lab.subboards, ", ", & &1.name)}
404 + </span>
392 405 <button
393 406 :if={Kanban.can_manage?(@role)}
394 407 type="button"
@@ -415,6 +428,23 @@ defmodule WaxxWeb.BoardLive.Settings do
415 428 <.button class="btn btn-primary">Add label</.button>
416 429 </div>
417 430 <.color_picker field={@label_form[:color]} label="Color (optional)" />
431 +
432 + <fieldset :if={@subboards != []} class="flex flex-col gap-1">
433 + <legend class="text-xs opacity-70 mb-1">
434 + Limit to subboards (optional — leave all unchecked for a board-wide label)
435 + </legend>
436 + <%!-- Hidden entry keeps the key present so unchecking all clears the scope. --%>
437 + <input type="hidden" name="label[subboard_ids][]" value="" />
438 + <label :for={sb <- @subboards} class="flex items-center gap-2 text-sm">
439 + <input
440 + type="checkbox"
441 + name="label[subboard_ids][]"
442 + value={sb.id}
443 + class="checkbox checkbox-sm"
444 + />
445 + {sb.name}
446 + </label>
447 + </fieldset>
418 448 </.form>
419 449 </section>
420 450
modified lib/waxx_web/live/board_live/show.ex
+22 −3
@@ -537,7 +537,14 @@ defmodule WaxxWeb.BoardLive.Show do
537 537 label =
538 538 Enum.find(socket.assigns.board.labels, &(&1.id == label_id_int))
539 539
540 − if card && card.board_id == socket.assigns.board.id && label do
540 + # Only allow applying a label that's in scope for the card's subboard;
541 + # a label already on the card stays toggleable so it can be removed.
542 + in_scope? =
543 + card && label &&
544 + (Kanban.BoardLabel.applies_to_subboard?(label, card.subboard_id) or
545 + Enum.any?(card.labels, &(&1.id == label.id)))
546 +
547 + if card && card.board_id == socket.assigns.board.id && in_scope? do
541 548 case Kanban.toggle_card_label(card, label, actor: socket.assigns.current_scope.user) do
542 549 {:ok, _} ->
543 550 {:noreply, socket}
@@ -1106,11 +1113,11 @@ defmodule WaxxWeb.BoardLive.Show do
1106 1113 </div>
1107 1114 </div>
1108 1115
1109 − <div :if={@board.labels != []} class="p-4 border-b border-base-300">
1116 + <div :if={labels_for_card(@board.labels, @card) != []} class="p-4 border-b border-base-300">
1110 1117 <h3 class="text-xs font-semibold uppercase tracking-wider opacity-60 mb-2">Labels</h3>
1111 1118 <div class="flex flex-wrap gap-1">
1112 1119 <button
1113 − :for={lab <- @board.labels}
1120 + :for={lab <- labels_for_card(@board.labels, @card)}
1114 1121 type="button"
1115 1122 phx-click="toggle_label"
1116 1123 phx-value-card-id={@card.id}
@@ -1405,6 +1412,18 @@ defmodule WaxxWeb.BoardLive.Show do
1405 1412
1406 1413 defp label_style(_), do: ""
1407 1414
1415 + # Labels offered in a card's label picker: board-wide labels plus any
1416 + # scoped to the card's subboard, unioned with labels already on the card
1417 + # (so a label that was later scoped away can still be removed).
1418 + defp labels_for_card(labels, card) do
1419 + on_card = MapSet.new(card.labels, & &1.id)
1420 +
1421 + Enum.filter(labels, fn lab ->
1422 + Kanban.BoardLabel.applies_to_subboard?(lab, card.subboard_id) or
1423 + MapSet.member?(on_card, lab.id)
1424 + end)
1425 + end
1426 +
1408 1427 # Looks up a note id against the currently-expanded card. Keeps the
1409 1428 # match scoped to the card the user is actually viewing — a stray
1410 1429 # phx-value-id from a previous render can't reach a note on someone
added priv/repo/migrations/20260618170000_add_subboard_scoped_labels.exs
+18 −0
@@ -0,0 +1,18 @@
1 +defmodule Waxx.Repo.Migrations.AddSubboardScopedLabels do
2 + use Ecto.Migration
3 +
4 + # Join table letting a board label be restricted to one or more subboards.
5 + # A label with NO rows here is board-wide (applies to every card); a label
6 + # with rows applies only to cards living in those subboards.
7 + def change do
8 + create table(:board_label_subboards) do
9 + add :board_label_id, references(:board_labels, on_delete: :delete_all), null: false
10 + add :subboard_id, references(:board_subboards, on_delete: :delete_all), null: false
11 +
12 + timestamps(type: :utc_datetime)
13 + end
14 +
15 + create unique_index(:board_label_subboards, [:board_label_id, :subboard_id])
16 + create index(:board_label_subboards, [:subboard_id])
17 + end
18 +end
modified test/waxx/kanban_test.exs
+54 −0
@@ -479,6 +479,60 @@ defmodule Waxx.KanbanTest do
479 479 end
480 480 end
481 481
482 + describe "board labels: scope + upsert" do
483 + setup do
484 + user = AccountsFixtures.user_fixture()
485 + template = build_template_with_two_stages_and_transition(user)
486 + {:ok, board} = Kanban.create_board_from_template(user, template, %{"name" => "B"})
487 + %{user: user, board: board}
488 + end
489 +
490 + test "create returns :created and defaults to board-wide (no subboards)", %{board: board} do
491 + assert {:ok, :created, label} = Kanban.create_board_label(board, %{"name" => "wip"})
492 + assert Waxx.Kanban.BoardLabel.applies_to_subboard?(Repo.preload(label, :subboards), nil)
493 + end
494 +
495 + test "re-adding the same name updates color + scope in place (:updated)", %{board: board} do
496 + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "Alpha"})
497 +
498 + {:ok, :created, label} =
499 + Kanban.create_board_label(board, %{"name" => "wip", "color" => "#000000"})
500 +
501 + assert {:ok, :updated, updated} =
502 + Kanban.create_board_label(board, %{
503 + "name" => "wip",
504 + "color" => "#ffffff",
505 + "subboard_ids" => [sb.id]
506 + })
507 +
508 + assert updated.id == label.id
509 + assert updated.color == "#ffffff"
510 + assert Enum.map(Repo.preload(updated, :subboards).subboards, & &1.id) == [sb.id]
511 + end
512 +
513 + test "a scoped label only applies inside its subboards", %{board: board} do
514 + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "Alpha"})
515 +
516 + {:ok, :created, label} =
517 + Kanban.create_board_label(board, %{"name" => "alpha-only", "subboard_ids" => [sb.id]})
518 +
519 + label = Repo.preload(label, :subboards)
520 + assert Waxx.Kanban.BoardLabel.applies_to_subboard?(label, sb.id)
521 + refute Waxx.Kanban.BoardLabel.applies_to_subboard?(label, nil)
522 + end
523 +
524 + test "foreign subboard ids are ignored when scoping", %{user: user, board: board} do
525 + other_template = build_template_with_two_stages_and_transition(user)
526 + {:ok, other} = Kanban.create_board_from_template(user, other_template, %{"name" => "O"})
527 + {:ok, foreign_sb} = Kanban.create_subboard(other, %{"name" => "Nope"})
528 +
529 + {:ok, :created, label} =
530 + Kanban.create_board_label(board, %{"name" => "x", "subboard_ids" => [foreign_sb.id]})
531 +
532 + assert Repo.preload(label, :subboards).subboards == []
533 + end
534 + end
535 +
482 536 describe "template → board propagation" do
483 537 test "renaming a template stage renames the matching board stage" do
484 538 user = AccountsFixtures.user_fixture()
modified test/waxx_web/api/v1/board_labels_test.exs
+23 −4
@@ -38,16 +38,35 @@ defmodule WaxxWeb.Api.V1.BoardLabelsTest do
38 38 assert Repo.get_by(BoardLabel, board_id: board.id, name: "urgent")
39 39 end
40 40
41 − test "duplicate name on the same board is a 422", %{conn: conn} do
41 + test "re-adding an existing name updates its color in place (200)", %{conn: conn} do
42 42 %{token: token, board: board} = setup_owner()
43 − board_label_fixture(board, name: "urgent")
43 + existing = board_label_fixture(board, name: "urgent", color: "#000000")
44 44
45 45 conn =
46 46 conn
47 47 |> auth(token)
48 − |> post(~p"/api/v1/boards/#{board.id}/labels", %{name: "urgent"})
48 + |> post(~p"/api/v1/boards/#{board.id}/labels", %{name: "urgent", color: "#ff0000"})
49 +
50 + assert %{"label" => label} = json_response(conn, 200)
51 + assert label["id"] == existing.id
52 + assert label["color"] == "#ff0000"
53 + assert Repo.get(BoardLabel, existing.id).color == "#ff0000"
54 + end
55 +
56 + test "can scope a label to subboards", %{conn: conn} do
57 + %{token: token, board: board} = setup_owner()
58 + {:ok, sb} = Kanban.create_subboard(board, %{"name" => "Alpha"})
49 59
50 − assert json_response(conn, 422)["error"]["code"] == "validation_failed"
60 + conn =
61 + conn
62 + |> auth(token)
63 + |> post(~p"/api/v1/boards/#{board.id}/labels", %{
64 + name: "alpha-only",
65 + subboard_ids: [sb.id]
66 + })
67 +
68 + assert %{"label" => label} = json_response(conn, 201)
69 + assert label["subboard_ids"] == [sb.id]
51 70 end
52 71
53 72 test "non-owner gets 403", %{conn: conn} do

Parents: 6e4f27e