neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
subboard scoped labels
0b2ca62 · gmorell · 2026-06-18 22:22
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