some bits

a268c83 · gmorell · 2026-05-26 05:07

28 files +421 -62

Files changed

modified android/app/src/main/java/org/neiam/waxx/app/data/Models.kt
+11 −2
@@ -228,10 +228,19 @@ data class CardNote(
228 228 data class NoteResponse(val note: CardNote)
229 229
230 230 @Serializable
231 −data class CreateNoteBody(val body: String, val kind: String? = null)
231 +data class CreateNoteBody(
232 + val body: String,
233 + val kind: String? = null,
234 + val board_stage_id: String? = null,
235 +)
232 236
233 237 @Serializable
234 −data class UpdateNoteBody(val body: String? = null, val done: Boolean? = null)
238 +data class UpdateNoteBody(
239 + val body: String? = null,
240 + val done: Boolean? = null,
241 + val kind: String? = null,
242 + val board_stage_id: String? = null,
243 +)
235 244
236 245 @Serializable
237 246 data class SetFieldBody(val value: String? = null)
modified android/app/src/main/java/org/neiam/waxx/app/ui/BoardScreen.kt
+43 −13
@@ -410,16 +410,32 @@ private fun Kanban(
410 410 .padding(horizontal = 8.dp, vertical = 8.dp),
411 411 verticalArrangement = Arrangement.spacedBy(8.dp),
412 412 ) {
413 − // Column header
413 + // Column header — each stage label sits in a tinted box
414 + // matching its stage color so the grid header reads like the
415 + // 1-D cell headers below.
414 416 Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
415 417 Box(modifier = Modifier.width(120.dp))
416 418 sortedStages.forEach { stage ->
417 − Text(
418 − stage.name,
419 − fontWeight = FontWeight.SemiBold,
420 − style = MaterialTheme.typography.titleSmall,
421 − modifier = Modifier.width(240.dp).padding(horizontal = 4.dp),
422 − )
419 + val tint = parseStageColor(stage.color)
420 + Box(
421 + modifier = Modifier
422 + .width(240.dp)
423 + .then(
424 + if (tint != null) {
425 + Modifier.background(
426 + tint.copy(alpha = 0.35f),
427 + RoundedCornerShape(6.dp),
428 + )
429 + } else Modifier,
430 + )
431 + .padding(horizontal = 8.dp, vertical = 4.dp),
432 + ) {
433 + Text(
434 + stage.name,
435 + fontWeight = FontWeight.SemiBold,
436 + style = MaterialTheme.typography.titleSmall,
437 + )
438 + }
423 439 }
424 440 }
425 441 rows.forEach { sb ->
@@ -491,12 +507,26 @@ private fun StageCell(
491 507 ),
492 508 ) {
493 509 if (showStageLabel) {
494 − Text(
495 − stage.name,
496 − fontWeight = FontWeight.SemiBold,
497 − style = MaterialTheme.typography.titleSmall,
498 − modifier = Modifier.padding(bottom = 4.dp),
499 − )
510 + val tint = parseStageColor(stage.color)
511 + Box(
512 + modifier = Modifier
513 + .fillMaxWidth()
514 + .then(
515 + if (tint != null) {
516 + Modifier.background(
517 + tint.copy(alpha = 0.35f),
518 + RoundedCornerShape(6.dp),
519 + )
520 + } else Modifier,
521 + )
522 + .padding(horizontal = 8.dp, vertical = 6.dp),
523 + ) {
524 + Text(
525 + stage.name,
526 + fontWeight = FontWeight.SemiBold,
527 + style = MaterialTheme.typography.titleSmall,
528 + )
529 + }
500 530 }
501 531 Text(
502 532 "${cards.size} card${if (cards.size == 1) "" else "s"}",
modified android/app/src/main/java/org/neiam/waxx/app/ui/CardSheet.kt
+108 −18
@@ -1,5 +1,6 @@
1 1 package org.neiam.waxx.app.ui
2 2
3 +import androidx.compose.foundation.background
3 4 import androidx.compose.foundation.clickable
4 5 import androidx.compose.foundation.layout.Arrangement
5 6 import androidx.compose.foundation.layout.Box
@@ -317,6 +318,7 @@ fun CardSheet(
317 318 items(notes.sortedBy { it.position }, key = { it.id }) { n ->
318 319 NoteRow(
319 320 note = n,
321 + stages = workflow?.stages.orEmpty(),
320 322 enabled = creds != null && !busy,
321 323 onToggleDone = {
322 324 mutate {
@@ -326,6 +328,14 @@ fun CardSheet(
326 328 )
327 329 }
328 330 },
331 + onPickStage = { stageId ->
332 + mutate {
333 + WaxxClient.authenticated(creds!!).updateNote(
334 + noteId = n.id,
335 + body = UpdateNoteBody(board_stage_id = stageId),
336 + )
337 + }
338 + },
329 339 onDelete = {
330 340 mutate {
331 341 WaxxClient.authenticated(creds!!).deleteNote(n.id)
@@ -335,12 +345,18 @@ fun CardSheet(
335 345 }
336 346 item {
337 347 AddNoteRow(
348 + stages = workflow?.stages.orEmpty(),
349 + currentStageId = stageId,
338 350 enabled = creds != null && !busy,
339 − onAdd = { body, kind ->
351 + onAdd = { body, kind, stageIdForNote ->
340 352 mutate {
341 353 WaxxClient.authenticated(creds!!).createNote(
342 354 cardId = initialCard.id,
343 − body = CreateNoteBody(body = body, kind = kind),
355 + body = CreateNoteBody(
356 + body = body,
357 + kind = kind,
358 + board_stage_id = stageIdForNote,
359 + ),
344 360 )
345 361 }
346 362 },
@@ -483,39 +499,87 @@ private fun FieldRow(
483 499 @Composable
484 500 private fun NoteRow(
485 501 note: org.neiam.waxx.app.data.CardNote,
502 + stages: List<org.neiam.waxx.app.data.Stage>,
486 503 enabled: Boolean,
487 504 onToggleDone: () -> Unit,
505 + onPickStage: (String) -> Unit,
488 506 onDelete: () -> Unit,
489 507 ) {
490 − Row(
491 − modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
492 − horizontalArrangement = Arrangement.spacedBy(8.dp),
508 + var menuOpen by remember { mutableStateOf(false) }
509 + val stage = stages.firstOrNull { it.id == note.board_stage_id }
510 + val tint = parseStageColor(stage?.color)
511 + ?: MaterialTheme.colorScheme.surfaceVariant
512 + val shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
513 +
514 + Column(
515 + modifier = Modifier
516 + .fillMaxWidth()
517 + .padding(vertical = 2.dp)
518 + .background(tint.copy(alpha = 0.35f), shape)
519 + .padding(8.dp),
493 520 ) {
494 − if (note.kind == "todo") {
495 − Checkbox(
496 − checked = note.done,
497 − enabled = enabled,
498 − onCheckedChange = { onToggleDone() },
521 + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
522 + if (note.kind == "todo") {
523 + Checkbox(
524 + checked = note.done,
525 + enabled = enabled,
526 + onCheckedChange = { onToggleDone() },
527 + )
528 + } else {
529 + Box(modifier = Modifier.width(40.dp).height(20.dp))
530 + }
531 + Text(
532 + note.body,
533 + style = MaterialTheme.typography.bodyMedium,
534 + modifier = Modifier.weight(1f),
499 535 )
500 − } else {
501 − Box(modifier = Modifier.width(40.dp).height(20.dp))
536 + if (enabled) {
537 + IconButton(onClick = onDelete) {
538 + Icon(Icons.Default.Delete, contentDescription = "Delete note")
539 + }
540 + }
502 541 }
503 − Text(note.body, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
504 − if (enabled) {
505 − IconButton(onClick = onDelete) {
506 − Icon(Icons.Default.Delete, contentDescription = "Delete note")
542 + // Stage label + tap-to-reassign. The whole row already shows the
543 + // stage color; this just labels which stage it is and exposes the
544 + // picker.
545 + Row(modifier = Modifier.padding(start = 48.dp)) {
546 + Box {
547 + androidx.compose.material3.AssistChip(
548 + enabled = enabled && stages.isNotEmpty(),
549 + onClick = { menuOpen = true },
550 + label = { Text(stage?.name ?: "—") },
551 + )
552 + androidx.compose.material3.DropdownMenu(
553 + expanded = menuOpen,
554 + onDismissRequest = { menuOpen = false },
555 + ) {
556 + stages.sortedBy { it.position }.forEach { s ->
557 + androidx.compose.material3.DropdownMenuItem(
558 + text = { Text(s.name) },
559 + onClick = {
560 + menuOpen = false
561 + if (s.id != note.board_stage_id) onPickStage(s.id)
562 + },
563 + )
564 + }
565 + }
507 566 }
508 567 }
509 568 }
510 569 }
511 570
571 +// parseStageColor moved to ui/StageColor.kt — also used by BoardScreen.
572 +
512 573 @Composable
513 574 private fun AddNoteRow(
575 + stages: List<org.neiam.waxx.app.data.Stage>,
576 + currentStageId: String,
514 577 enabled: Boolean,
515 − onAdd: (body: String, kind: String?) -> Unit,
578 + onAdd: (body: String, kind: String?, stageId: String?) -> Unit,
516 579 ) {
517 580 var body by remember { mutableStateOf("") }
518 581 var asTodo by remember { mutableStateOf(false) }
582 + var stageId by remember(currentStageId) { mutableStateOf(currentStageId) }
519 583
520 584 Column(modifier = Modifier.fillMaxWidth().padding(top = 4.dp)) {
521 585 OutlinedTextField(
@@ -525,6 +589,28 @@ private fun AddNoteRow(
525 589 modifier = Modifier.fillMaxWidth(),
526 590 singleLine = false,
527 591 )
592 + // Stage picker — defaults to the card's current stage but lets
593 + // the user log against any stage on the board.
594 + if (stages.isNotEmpty()) {
595 + Text(
596 + "Log against",
597 + style = MaterialTheme.typography.labelSmall,
598 + color = MaterialTheme.colorScheme.onSurfaceVariant,
599 + modifier = Modifier.padding(top = 4.dp),
600 + )
601 + Row(
602 + modifier = Modifier.fillMaxWidth(),
603 + horizontalArrangement = Arrangement.spacedBy(6.dp),
604 + ) {
605 + stages.sortedBy { it.position }.forEach { s ->
606 + FilterChip(
607 + selected = s.id == stageId,
608 + onClick = { stageId = s.id },
609 + label = { Text(s.name) },
610 + )
611 + }
612 + }
613 + }
528 614 Row(
529 615 modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
530 616 horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -543,7 +629,11 @@ private fun AddNoteRow(
543 629 onClick = {
544 630 val text = body.trim()
545 631 body = ""
546 − onAdd(text, if (asTodo) "todo" else null)
632 + // Omit the stage param when the user kept the default
633 + // (card's current stage) — the server already does
634 + // that when board_stage_id is absent.
635 + val explicit = if (stageId != currentStageId) stageId else null
636 + onAdd(text, if (asTodo) "todo" else null, explicit)
547 637 },
548 638 ) {
549 639 Icon(Icons.Default.Add, contentDescription = null); Text(" Add")
added android/app/src/main/java/org/neiam/waxx/app/ui/StageColor.kt
+24 −0
@@ -0,0 +1,24 @@
1 +package org.neiam.waxx.app.ui
2 +
3 +import androidx.compose.ui.graphics.Color
4 +
5 +/**
6 + * Parses a stage color stored as a hex string (`#RGB`, `#RRGGBB`,
7 + * `#AARRGGBB`) into a Compose [Color]. Returns null on null / blank /
8 + * unparseable input so callers can fall back to a Material default.
9 + */
10 +fun parseStageColor(hex: String?): Color? {
11 + if (hex.isNullOrBlank()) return null
12 + val s = hex.trim().removePrefix("#")
13 + return runCatching {
14 + when (s.length) {
15 + 6 -> Color(android.graphics.Color.parseColor("#FF$s"))
16 + 8 -> Color(android.graphics.Color.parseColor("#$s"))
17 + 3 -> {
18 + val r = s[0]; val g = s[1]; val b = s[2]
19 + Color(android.graphics.Color.parseColor("#FF$r$r$g$g$b$b"))
20 + }
21 + else -> null
22 + }
23 + }.getOrNull()
24 +}
modified lib/waxx_web/api/v1/note_controller.ex
+47 −8
@@ -1,11 +1,18 @@
1 1 defmodule WaxxWeb.Api.V1.NoteController do
2 2 @moduledoc """
3 − Card notes (Phase 4b).
3 + Card notes / todos.
4 4
5 − POST /api/v1/cards/:card_id/notes {body, kind?}
6 − PATCH /api/v1/notes/:id {body?, done?}
5 + POST /api/v1/cards/:card_id/notes {body, kind?, board_stage_id?}
6 + PATCH /api/v1/notes/:id {body?, done?, kind?, board_stage_id?}
7 7 DELETE /api/v1/notes/:id
8 8
9 + `board_stage_id` is optional on create — omitted means "attribute to
10 + the card's current stage" (what `Kanban.add_card_note` already does).
11 + Passing an explicit id lets the caller log a note against a different
12 + stage entirely; PATCH lets them re-assign an existing note. Both paths
13 + validate the stage belongs to the note's card's board so a crafted
14 + payload can't pin a note to some other board's stage.
15 +
9 16 Same auth/role gating as `CardController`: the caller must have
10 17 owner or editor membership on the note's card's board.
11 18 """
@@ -13,7 +20,7 @@ defmodule WaxxWeb.Api.V1.NoteController do
13 20 use WaxxWeb, :controller
14 21
15 22 alias Waxx.{Kanban, Repo}
16 − alias Waxx.Kanban.{Card, CardNote}
23 + alias Waxx.Kanban.{BoardStage, Card, CardNote}
17 24 alias WaxxWeb.Api.V1.BoardJSON
18 25
19 26 action_fallback WaxxWeb.Api.FallbackController
@@ -22,7 +29,7 @@ defmodule WaxxWeb.Api.V1.NoteController do
22 29 user = conn.assigns.current_scope.user
23 30
24 31 with {:ok, card} <- fetch_editable_card(card_id, user),
25 − attrs <- Map.take(params, ["body", "kind"]),
32 + {:ok, attrs} <- build_create_attrs(card, params),
26 33 {:ok, note} <- Kanban.add_card_note(card, user, attrs) do
27 34 conn
28 35 |> put_status(:created)
@@ -33,9 +40,8 @@ defmodule WaxxWeb.Api.V1.NoteController do
33 40 def update(conn, %{"id" => id} = params) do
34 41 user = conn.assigns.current_scope.user
35 42
36 − with {:ok, note} <- fetch_editable_note(id, user) do
37 − attrs = Map.take(params, ["body", "done"])
38 −
43 + with {:ok, note} <- fetch_editable_note(id, user),
44 + {:ok, attrs} <- build_update_attrs(note, params) do
39 45 case Kanban.update_card_note(note, attrs) do
40 46 {:ok, updated} -> json(conn, BoardJSON.note_response(updated))
41 47 {:error, changeset} -> {:error, changeset}
@@ -43,6 +49,39 @@ defmodule WaxxWeb.Api.V1.NoteController do
43 49 end
44 50 end
45 51
52 + defp build_create_attrs(%Card{board_id: board_id}, params) do
53 + attrs = Map.take(params, ["body", "kind"])
54 +
55 + case params["board_stage_id"] do
56 + nil -> {:ok, attrs}
57 + "" -> {:ok, attrs}
58 + stage_id -> with_validated_stage(attrs, board_id, stage_id)
59 + end
60 + end
61 +
62 + defp build_update_attrs(%CardNote{card_id: card_id}, params) do
63 + attrs = Map.take(params, ["body", "done", "kind"])
64 +
65 + if Map.has_key?(params, "board_stage_id") do
66 + %Card{board_id: board_id} = Repo.get!(Card, card_id)
67 +
68 + case params["board_stage_id"] do
69 + nil -> {:error, :validation_failed}
70 + "" -> {:error, :validation_failed}
71 + stage_id -> with_validated_stage(attrs, board_id, stage_id)
72 + end
73 + else
74 + {:ok, attrs}
75 + end
76 + end
77 +
78 + defp with_validated_stage(attrs, board_id, stage_id) do
79 + case Repo.get(BoardStage, stage_id) do
80 + %BoardStage{board_id: ^board_id} -> {:ok, Map.put(attrs, "board_stage_id", stage_id)}
81 + _ -> {:error, :validation_failed}
82 + end
83 + end
84 +
46 85 def delete(conn, %{"id" => id}) do
47 86 user = conn.assigns.current_scope.user
48 87
modified lib/waxx_web/live/board_live/show.ex
+136 −21
@@ -35,7 +35,8 @@ defmodule WaxxWeb.BoardLive.Show do
35 35 |> assign(:card_edit_form, nil)
36 36 |> assign(:hide_label_text, Accounts.hide_label_text?(user, board_id))
37 37 |> assign(:card_templates, Kanban.list_card_templates(board))
38 − |> assign(:new_card_form, to_form(%{"title" => "", "description" => ""}, as: "card"))}
38 + |> assign(:new_card_form, to_form(%{"title" => "", "description" => ""}, as: "card"))
39 + |> assign(:note_stage_picker_open_for, nil)}
39 40 end
40 41 end
41 42
@@ -413,7 +414,7 @@ defmodule WaxxWeb.BoardLive.Show do
413 414 end)
414 415 end
415 416
416 − def handle_event("add_note", %{"id" => card_id, "body" => body, "kind" => kind}, socket) do
417 + def handle_event("add_note", %{"id" => card_id} = params, socket) do
417 418 guard_edit(socket, fn ->
418 419 card = Kanban.get_card(card_id)
419 420 user = socket.assigns.current_scope.user
@@ -423,7 +424,26 @@ defmodule WaxxWeb.BoardLive.Show do
423 424 {:noreply, put_flash(socket, :error, "Card not found.")}
424 425
425 426 true ->
426 − case Kanban.add_card_note(card, user, %{"body" => body, "kind" => kind}) do
427 + attrs = %{
428 + "body" => params["body"],
429 + "kind" => params["kind"]
430 + }
431 +
432 + # Optional stage override. Must belong to this card's board —
433 + # the LiveView form only ever offers stages from `@board.stages`,
434 + # but defence-in-depth.
435 + attrs =
436 + case params["board_stage_id"] do
437 + s when is_binary(s) and s != "" ->
438 + if Enum.any?(socket.assigns.board.stages, &(&1.id == s)),
439 + do: Map.put(attrs, "board_stage_id", s),
440 + else: attrs
441 +
442 + _ ->
443 + attrs
444 + end
445 +
446 + case Kanban.add_card_note(card, user, attrs) do
427 447 {:ok, _} -> {:noreply, socket}
428 448 {:error, _} -> {:noreply, put_flash(socket, :error, "Couldn't add the note.")}
429 449 end
@@ -457,6 +477,30 @@ defmodule WaxxWeb.BoardLive.Show do
457 477 end)
458 478 end
459 479
480 + # Click on a note's stage label toggles a per-note inline picker.
481 + # Re-clicking the same note (or another) closes / re-opens accordingly.
482 + def handle_event("toggle_note_stage_picker", %{"id" => id}, socket) do
483 + current = socket.assigns.note_stage_picker_open_for
484 + next = if current == id, do: nil, else: id
485 + {:noreply, assign(socket, :note_stage_picker_open_for, next)}
486 + end
487 +
488 + def handle_event("set_note_stage", %{"id" => id, "stage-id" => stage_id}, socket) do
489 + guard_edit(socket, fn ->
490 + with %{} = note <- find_note_on_open_card(socket, id),
491 + true <- Enum.any?(socket.assigns.board.stages, &(&1.id == stage_id)),
492 + {:ok, _} <- Kanban.update_card_note(note, %{board_stage_id: stage_id}) do
493 + {:noreply, assign(socket, :note_stage_picker_open_for, nil)}
494 + else
495 + _ ->
496 + {:noreply,
497 + socket
498 + |> assign(:note_stage_picker_open_for, nil)
499 + |> put_flash(:error, "Couldn't reassign the note.")}
500 + end
501 + end)
502 + end
503 +
460 504 def handle_event("save_card_as_template", %{"id" => card_id, "name" => name}, socket) do
461 505 guard_edit(socket, fn ->
462 506 card = Kanban.get_card(card_id)
@@ -671,6 +715,7 @@ defmodule WaxxWeb.BoardLive.Show do
671 715 targets={Kanban.allowed_targets(@expanded_card)}
672 716 editing={@editing_card}
673 717 edit_form={@card_edit_form}
718 + note_stage_picker_open_for={@note_stage_picker_open_for}
674 719 />
675 720 <% end %>
676 721
@@ -983,6 +1028,7 @@ defmodule WaxxWeb.BoardLive.Show do
983 1028 attr :targets, :list, required: true
984 1029 attr :editing, :boolean, default: false
985 1030 attr :edit_form, :any, default: nil
1031 + attr :note_stage_picker_open_for, :any, default: nil
986 1032
987 1033 defp card_detail(assigns) do
988 1034 ~H"""
@@ -1181,8 +1227,8 @@ defmodule WaxxWeb.BoardLive.Show do
1181 1227 <li
1182 1228 :for={n <- @card.notes}
1183 1229 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"}"}
1230 + class="flex items-start gap-2 rounded px-2 py-1"
1231 + style={"background-color: #{note_stage_color_bg(n, @board)}"}
1186 1232 >
1187 1233 <%= if n.kind == "todo" do %>
1188 1234 <input
@@ -1203,9 +1249,39 @@ defmodule WaxxWeb.BoardLive.Show do
1203 1249 ]}>
1204 1250 {n.body}
1205 1251 </p>
1206 − <p class="text-[10px] opacity-60 mt-0.5">
1207 − {note_stage_name(n, @board)}
1208 − </p>
1252 + <%= if Kanban.can_edit?(@role) do %>
1253 + <button
1254 + type="button"
1255 + phx-click="toggle_note_stage_picker"
1256 + phx-value-id={n.id}
1257 + class="text-[10px] opacity-60 mt-0.5 hover:opacity-100 hover:underline cursor-pointer"
1258 + title="Click to reassign stage"
1259 + >
1260 + {note_stage_name(n, @board)}
1261 + </button>
1262 + <div
1263 + :if={@note_stage_picker_open_for == n.id}
1264 + class="flex flex-wrap gap-1 mt-1"
1265 + >
1266 + <button
1267 + :for={s <- @board.stages}
1268 + type="button"
1269 + phx-click="set_note_stage"
1270 + phx-value-id={n.id}
1271 + phx-value-stage-id={s.id}
1272 + class={[
1273 + "btn btn-xs btn-outline",
1274 + s.id == n.board_stage_id and "btn-active"
1275 + ]}
1276 + >
1277 + {s.name}
1278 + </button>
1279 + </div>
1280 + <% else %>
1281 + <p class="text-[10px] opacity-60 mt-0.5">
1282 + {note_stage_name(n, @board)}
1283 + </p>
1284 + <% end %>
1209 1285 </div>
1210 1286 <button
1211 1287 :if={Kanban.can_edit?(@role)}
@@ -1226,20 +1302,30 @@ defmodule WaxxWeb.BoardLive.Show do
1226 1302 as={:note}
1227 1303 phx-submit="add_note"
1228 1304 phx-value-id={@card.id}
1229 − class="flex items-end gap-2"
1305 + class="flex flex-col gap-2"
1230 1306 >
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>
1307 + <div class="flex items-end gap-2">
1308 + <input
1309 + type="text"
1310 + name="body"
1311 + placeholder="Write a note or todo…"
1312 + required
1313 + class="input input-sm input-bordered flex-1"
1314 + />
1315 + <select name="kind" class="select select-sm select-bordered">
1316 + <option value="note">Note</option>
1317 + <option value="todo">Todo</option>
1318 + </select>
1319 + <.button class="btn btn-primary btn-sm">Add</.button>
1320 + </div>
1321 + <label class="text-xs opacity-70 flex items-center gap-2">
1322 + Log against
1323 + <select name="board_stage_id" class="select select-xs select-bordered">
1324 + <option :for={s <- @board.stages} value={s.id} selected={s.id == @card.board_stage_id}>
1325 + {s.name}
1326 + </option>
1327 + </select>
1328 + </label>
1243 1329 </.form>
1244 1330 </div>
1245 1331
@@ -1339,6 +1425,35 @@ defmodule WaxxWeb.BoardLive.Show do
1339 1425 end
1340 1426 end
1341 1427
1428 + # Background tint for a note row. Returns "transparent" when there's no
1429 + # stage color; otherwise an rgba(...) with the stage hue at low alpha
1430 + # so body text stays readable. Matches the Android card sheet's
1431 + # whole-row tint (see ui/CardSheet.kt's `NoteRow`).
1432 + defp note_stage_color_bg(note, board) do
1433 + case note_stage_color(note, board) do
1434 + nil -> "transparent"
1435 + hex -> hex_to_rgba(hex, 0.18) || "transparent"
1436 + end
1437 + end
1438 +
1439 + defp hex_to_rgba("#" <> rest, alpha), do: hex_to_rgba(rest, alpha)
1440 +
1441 + defp hex_to_rgba(<<r1, r2, g1, g2, b1, b2>>, alpha) do
1442 + with {r, ""} <- Integer.parse(<<r1, r2>>, 16),
1443 + {g, ""} <- Integer.parse(<<g1, g2>>, 16),
1444 + {b, ""} <- Integer.parse(<<b1, b2>>, 16) do
1445 + "rgba(#{r}, #{g}, #{b}, #{alpha})"
1446 + else
1447 + _ -> nil
1448 + end
1449 + end
1450 +
1451 + defp hex_to_rgba(<<r, g, b>>, alpha) do
1452 + hex_to_rgba(<<r, r, g, g, b, b>>, alpha)
1453 + end
1454 +
1455 + defp hex_to_rgba(_, _), do: nil
1456 +
1342 1457 defp note_stage_name(%{board_stage_id: nil}, _board), do: "(no stage)"
1343 1458
1344 1459 defp note_stage_name(%{board_stage_id: stage_id}, board) do
modified test/waxx_web/api/v1/card_relations_test.exs
+52 −0
@@ -225,5 +225,57 @@ defmodule WaxxWeb.Api.V1.CardRelationsTest do
225 225
226 226 assert json_response(conn, 403)
227 227 end
228 +
229 + test "create accepts an explicit board_stage_id on the same board", %{conn: conn} do
230 + %{token: token, board: board, user: user} = setup_owner()
231 + card = card_fixture(board, user)
232 + [_todo, done] = board.stages
233 +
234 + conn =
235 + conn
236 + |> auth(token)
237 + |> post(~p"/api/v1/cards/#{card.id}/notes", %{
238 + body: "log this against Done",
239 + board_stage_id: done.id
240 + })
241 +
242 + assert %{"note" => n} = json_response(conn, 201)
243 + assert n["board_stage_id"] == done.id
244 + end
245 +
246 + test "create rejects a stage from a different board", %{conn: conn} do
247 + %{token: token, board: board, user: user} = setup_owner()
248 + card = card_fixture(board, user)
249 +
250 + other_owner = confirmed_user_fixture()
251 + other_board = board_fixture(other_owner)
252 + [foreign_stage, _] = other_board.stages
253 +
254 + conn =
255 + conn
256 + |> auth(token)
257 + |> post(~p"/api/v1/cards/#{card.id}/notes", %{
258 + body: "should fail",
259 + board_stage_id: foreign_stage.id
260 + })
261 +
262 + assert json_response(conn, 422)["error"]["code"] == "validation_failed"
263 + end
264 +
265 + test "update can re-assign the stage", %{conn: conn} do
266 + %{token: token, board: board, user: user} = setup_owner()
267 + card = card_fixture(board, user)
268 + [todo, done] = board.stages
269 + {:ok, note} = Kanban.add_card_note(card, user, %{"body" => "x"})
270 + assert note.board_stage_id == todo.id
271 +
272 + conn =
273 + conn
274 + |> auth(token)
275 + |> patch(~p"/api/v1/notes/#{note.id}", %{board_stage_id: done.id})
276 +
277 + assert %{"note" => n} = json_response(conn, 200)
278 + assert n["board_stage_id"] == done.id
279 + end
228 280 end
229 281 end

Parents: 7e90bf5