neiam
/waxx
waxx
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
some bits
a268c83 · gmorell · 2026-05-26 05:07
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