Skip to content

Commit 2d4ea5d

Browse files
authored
Merge pull request #13 from markrai/enhancement/workflow-editing-lane-color
enhancement: lane color change capability post creation
2 parents ccf5483 + 47be39d commit 2d4ea5d

9 files changed

Lines changed: 277 additions & 56 deletions

File tree

internal/httpapi/routing.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,7 +1115,7 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request, rest []stri
11151115
return
11161116
}
11171117

1118-
// PATCH /api/board/{slug}/workflow/{key} - rename workflow lane label only.
1118+
// PATCH /api/board/{slug}/workflow/{key} - update workflow lane label and color.
11191119
if len(rest) == 3 && rest[1] == "workflow" && r.Method == http.MethodPatch {
11201120
ctx := s.requestContext(r)
11211121
userID, ok := store.UserIDFromContext(ctx)
@@ -1135,12 +1135,14 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request, rest []stri
11351135
}
11361136

11371137
var in struct {
1138-
Name string `json:"name"`
1138+
Name string `json:"name"`
1139+
Color string `json:"color"`
11391140
}
11401141
if err := readJSON(w, r, s.maxBody, &in); err != nil {
11411142
return
11421143
}
11431144
in.Name = strings.TrimSpace(in.Name)
1145+
in.Color = strings.TrimSpace(in.Color)
11441146
if in.Name == "" {
11451147
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "name required", map[string]any{"field": "name"})
11461148
return
@@ -1149,11 +1151,19 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request, rest []stri
11491151
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "invalid workflow column name", map[string]any{"field": "name"})
11501152
return
11511153
}
1152-
if err := s.store.UpdateWorkflowColumnName(ctx, project.ID, columnKey, in.Name); err != nil {
1154+
if in.Color == "" {
1155+
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "color required", map[string]any{"field": "color"})
1156+
return
1157+
}
1158+
if !store.ValidWorkflowColumnColor(in.Color) {
1159+
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "invalid workflow column color", map[string]any{"field": "color"})
1160+
return
1161+
}
1162+
if err := s.store.UpdateWorkflowColumn(ctx, project.ID, columnKey, in.Name, in.Color); err != nil {
11531163
writeStoreErr(w, err, true)
11541164
return
11551165
}
1156-
s.emitRefreshNeeded(project.ID, "workflow_column_renamed")
1166+
s.emitRefreshNeeded(project.ID, "workflow_column_updated")
11571167
w.WriteHeader(http.StatusNoContent)
11581168
return
11591169
}

internal/httpapi/server.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ type storeAPI interface {
8181
UpdateProjectDefaultSprintWeeks(ctx context.Context, projectID int64, userID int64, weeks int) error
8282
AddWorkflowColumn(ctx context.Context, projectID int64, name string) (store.WorkflowColumn, error)
8383
DeleteWorkflowColumn(ctx context.Context, projectID int64, key string) error
84-
UpdateWorkflowColumnName(ctx context.Context, projectID int64, key, newName string) error
84+
UpdateWorkflowColumn(ctx context.Context, projectID int64, key, name, color string) error
8585
GetProjectRole(ctx context.Context, projectID int64, userID int64) (store.ProjectRole, error)
8686
CheckProjectRole(ctx context.Context, projectID int64, userID int64, requiredRole store.ProjectRole) error
8787
ListProjectMembers(ctx context.Context, projectID int64, userID int64) ([]store.ProjectMember, error)

internal/httpapi/server_test.go

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,8 @@ func TestRenameLane_RequiresMaintainer(t *testing.T) {
224224
loginUserClient(t, contributorClient, ts.URL, "contrib@example.com", "password123")
225225

226226
resp, body := doJSON(t, contributorClient, http.MethodPatch, ts.URL+"/api/board/"+project.Slug+"/workflow/"+store.DefaultColumnDoing, map[string]any{
227-
"name": "Working",
227+
"name": "Working",
228+
"color": "#10B981",
228229
}, nil)
229230
if resp.StatusCode != http.StatusForbidden {
230231
t.Fatalf("expected 403, got %d body=%s", resp.StatusCode, string(body))
@@ -246,7 +247,8 @@ func TestRenameLane_NonexistentKeyReturns404(t *testing.T) {
246247
}
247248

248249
resp, body := doJSON(t, ownerClient, http.MethodPatch, ts.URL+"/api/board/"+project.Slug+"/workflow/not_a_lane", map[string]any{
249-
"name": "Working",
250+
"name": "Working",
251+
"color": "#10B981",
250252
}, nil)
251253
if resp.StatusCode != http.StatusNotFound {
252254
t.Fatalf("expected 404, got %d body=%s", resp.StatusCode, string(body))
@@ -273,23 +275,31 @@ func TestRenameLane_EmptyNameRejected(t *testing.T) {
273275
}{
274276
{
275277
name: "WhitespaceOnly",
276-
body: map[string]any{"name": " "},
278+
body: map[string]any{"name": " ", "color": "#10B981"},
277279
},
278280
{
279-
name: "RejectsColor",
280-
body: map[string]any{"name": "Working", "color": "#123456"},
281+
name: "EmptyColor",
282+
body: map[string]any{"name": "Working", "color": ""},
283+
},
284+
{
285+
name: "MissingColor",
286+
body: map[string]any{"name": "Working"},
287+
},
288+
{
289+
name: "InvalidColor",
290+
body: map[string]any{"name": "Working", "color": "#gggggg"},
281291
},
282292
{
283293
name: "RejectsKey",
284-
body: map[string]any{"name": "Working", "key": "other"},
294+
body: map[string]any{"name": "Working", "color": "#10B981", "key": "other"},
285295
},
286296
{
287297
name: "RejectsIsDone",
288-
body: map[string]any{"name": "Working", "isDone": true},
298+
body: map[string]any{"name": "Working", "color": "#10B981", "isDone": true},
289299
},
290300
{
291301
name: "RejectsPosition",
292-
body: map[string]any{"name": "Working", "position": 1},
302+
body: map[string]any{"name": "Working", "color": "#10B981", "position": 1},
293303
},
294304
}
295305
for _, tc := range tests {
@@ -317,16 +327,18 @@ func TestRenameLane_BoardAPIReflectsNewName(t *testing.T) {
317327
}
318328

319329
resp, body := doJSON(t, ownerClient, http.MethodPatch, ts.URL+"/api/board/"+project.Slug+"/workflow/"+store.DefaultColumnDoing, map[string]any{
320-
"name": "Working",
330+
"name": "Working",
331+
"color": "#aabbcc",
321332
}, nil)
322333
if resp.StatusCode != http.StatusNoContent {
323334
t.Fatalf("rename lane status=%d body=%s", resp.StatusCode, string(body))
324335
}
325336

326337
var board struct {
327338
ColumnOrder []struct {
328-
Key string `json:"key"`
329-
Name string `json:"name"`
339+
Key string `json:"key"`
340+
Name string `json:"name"`
341+
Color string `json:"color"`
330342
} `json:"columnOrder"`
331343
}
332344
resp, body = doJSON(t, ownerClient, http.MethodGet, ts.URL+"/api/board/"+project.Slug, nil, &board)
@@ -338,6 +350,9 @@ func TestRenameLane_BoardAPIReflectsNewName(t *testing.T) {
338350
if lane.Name != "Working" {
339351
t.Fatalf("expected lane name %q, got %q", "Working", lane.Name)
340352
}
353+
if lane.Color != "#aabbcc" {
354+
t.Fatalf("expected lane color %q, got %q", "#aabbcc", lane.Color)
355+
}
341356
return
342357
}
343358
}

internal/httpapi/web/dist/dialogs/settings.js

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,11 @@ function msToDateTimeLocalStr(ms) {
575575
const mm = String(d.getMinutes()).padStart(2, "0");
576576
return `${y}-${m}-${day}T${hh}:${mm}`;
577577
}
578+
const DEFAULT_WORKFLOW_LANE_COLOR = "#64748b";
579+
function normalizeWorkflowLaneColorForInput(color) {
580+
const s = color?.trim();
581+
return s && /^#[0-9a-fA-F]{6}$/.test(s) ? s : DEFAULT_WORKFLOW_LANE_COLOR;
582+
}
578583
function renderWorkflowTabContent() {
579584
const board = getBoard();
580585
const workflow = board?.columnOrder ?? [];
@@ -588,7 +593,7 @@ function renderWorkflowTabContent() {
588593
return `
589594
<div class="settings-section">
590595
<div class="settings-section__title">Workflow</div>
591-
<div class="settings-section__description muted">Rename lane labels or add a non-done lane inserted immediately before the done lane (whatever its label). Keys stay immutable.</div>
596+
<div class="settings-section__description muted">Rename lane labels and colors, or add a non-done lane inserted immediately before the done lane (whatever its label). Keys stay immutable.</div>
592597
<div class="settings-workflow-create" style="display:flex; gap:12px; align-items:flex-end; margin-bottom:16px;">
593598
<label class="field" style="flex:1; min-width:0; margin:0;">
594599
<div class="field__label">New lane name</div>
@@ -614,6 +619,14 @@ function renderWorkflowTabContent() {
614619
aria-label="Lane label for ${escapeHTML(lane.key)}"
615620
style="flex:1; min-width:0;"
616621
/>
622+
<input
623+
type="color"
624+
class="settings-color-picker"
625+
data-workflow-color="${escapeHTML(lane.key)}"
626+
value="${escapeHTML(normalizeWorkflowLaneColorForInput(lane.color))}"
627+
aria-label="Lane color for ${escapeHTML(lane.key)}"
628+
title="Lane color"
629+
/>
617630
<button class="btn btn--ghost btn--small" type="button" data-workflow-save="${escapeHTML(lane.key)}">Save</button>
618631
${lane.isDone ? `<button class="btn btn--ghost btn--small" type="button" disabled aria-disabled="true" title="Done lane cannot be deleted">Delete</button>` : `<button class="btn btn--danger btn--small" type="button" data-workflow-delete="${escapeHTML(lane.key)}" ${canDeleteAnyLane ? "" : `disabled aria-disabled="true" title="Workflow must keep at least 2 lanes"`}>Delete</button>`}
619632
</div>
@@ -647,32 +660,35 @@ async function addWorkflowLane(name) {
647660
showToast(err.message || "Failed to add lane");
648661
}
649662
}
650-
async function renameWorkflowLaneLabel(key, name) {
663+
async function updateWorkflowLane(key, payload) {
651664
const slug = getSlug();
652665
if (!slug) {
653666
showToast("No project available");
654667
return;
655668
}
656-
const trimmed = name.trim();
669+
const trimmed = payload.name.trim();
657670
if (!trimmed) {
658671
showToast("Lane name is required");
659672
return;
660673
}
661-
const currentName = getBoard()?.columnOrder?.find((lane) => lane.key === key)?.name?.trim();
662-
if (currentName === trimmed)
674+
const color = payload.color.trim();
675+
const lane = getBoard()?.columnOrder?.find((l) => l.key === key);
676+
const prevColor = (lane?.color ?? DEFAULT_WORKFLOW_LANE_COLOR).toLowerCase();
677+
if (trimmed === lane?.name?.trim() && color.toLowerCase() === prevColor) {
663678
return;
679+
}
664680
try {
665681
recordLocalMutation();
666682
await apiFetch(`/api/board/${slug}/workflow/${encodeURIComponent(key)}`, {
667683
method: "PATCH",
668-
body: JSON.stringify({ name: trimmed }),
684+
body: JSON.stringify({ name: trimmed, color }),
669685
});
670686
await invalidateBoard(slug, getTag(), getSearch(), getSprintIdFromUrl());
671687
await renderSettingsModal();
672-
showToast("Lane label updated");
688+
showToast("Lane updated");
673689
}
674690
catch (err) {
675-
showToast(err.message || "Failed to update lane label");
691+
showToast(err.message || "Failed to update lane");
676692
}
677693
}
678694
async function deleteWorkflowLane(key) {
@@ -1341,18 +1357,20 @@ export async function renderSettingsModal(options) {
13411357
addLane();
13421358
}, { signal });
13431359
}
1344-
const bindRename = (key) => {
1345-
const input = document.querySelector(`[data-workflow-name="${key}"]`);
1346-
if (!input)
1360+
const saveWorkflowLane = (key) => {
1361+
const row = document.querySelector(`[data-workflow-key="${key}"]`);
1362+
const nameInput = row?.querySelector("[data-workflow-name]");
1363+
const colorInput = row?.querySelector("[data-workflow-color]");
1364+
if (!nameInput || !colorInput)
13471365
return;
1348-
renameWorkflowLaneLabel(key, input.value);
1366+
updateWorkflowLane(key, { name: nameInput.value, color: colorInput.value });
13491367
};
13501368
document.querySelectorAll("[data-workflow-save]").forEach((btn) => {
13511369
btn.addEventListener("click", () => {
13521370
const key = btn.getAttribute("data-workflow-save");
13531371
if (!key)
13541372
return;
1355-
bindRename(key);
1373+
saveWorkflowLane(key);
13561374
}, { signal });
13571375
});
13581376
document.querySelectorAll("[data-workflow-name]").forEach((inputEl) => {
@@ -1363,7 +1381,18 @@ export async function renderSettingsModal(options) {
13631381
const key = inputEl.getAttribute("data-workflow-name");
13641382
if (!key)
13651383
return;
1366-
bindRename(key);
1384+
saveWorkflowLane(key);
1385+
}, { signal });
1386+
});
1387+
document.querySelectorAll("[data-workflow-color]").forEach((colorEl) => {
1388+
colorEl.addEventListener("keydown", (e) => {
1389+
if (e.key !== "Enter")
1390+
return;
1391+
e.preventDefault();
1392+
const key = colorEl.getAttribute("data-workflow-color");
1393+
if (!key)
1394+
return;
1395+
saveWorkflowLane(key);
13671396
}, { signal });
13681397
});
13691398
document.querySelectorAll("[data-workflow-delete]").forEach((btn) => {

internal/httpapi/web/modules/dialogs/settings.ts

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,13 @@ function msToDateTimeLocalStr(ms: number): string {
656656
return `${y}-${m}-${day}T${hh}:${mm}`;
657657
}
658658

659+
const DEFAULT_WORKFLOW_LANE_COLOR = "#64748b";
660+
661+
function normalizeWorkflowLaneColorForInput(color: string | undefined | null): string {
662+
const s = color?.trim();
663+
return s && /^#[0-9a-fA-F]{6}$/.test(s) ? s : DEFAULT_WORKFLOW_LANE_COLOR;
664+
}
665+
659666
function renderWorkflowTabContent(): string {
660667
const board = getBoard();
661668
const workflow = board?.columnOrder ?? [];
@@ -669,7 +676,7 @@ function renderWorkflowTabContent(): string {
669676
return `
670677
<div class="settings-section">
671678
<div class="settings-section__title">Workflow</div>
672-
<div class="settings-section__description muted">Rename lane labels or add a non-done lane inserted immediately before the done lane (whatever its label). Keys stay immutable.</div>
679+
<div class="settings-section__description muted">Rename lane labels and colors, or add a non-done lane inserted immediately before the done lane (whatever its label). Keys stay immutable.</div>
673680
<div class="settings-workflow-create" style="display:flex; gap:12px; align-items:flex-end; margin-bottom:16px;">
674681
<label class="field" style="flex:1; min-width:0; margin:0;">
675682
<div class="field__label">New lane name</div>
@@ -695,6 +702,14 @@ function renderWorkflowTabContent(): string {
695702
aria-label="Lane label for ${escapeHTML(lane.key)}"
696703
style="flex:1; min-width:0;"
697704
/>
705+
<input
706+
type="color"
707+
class="settings-color-picker"
708+
data-workflow-color="${escapeHTML(lane.key)}"
709+
value="${escapeHTML(normalizeWorkflowLaneColorForInput(lane.color))}"
710+
aria-label="Lane color for ${escapeHTML(lane.key)}"
711+
title="Lane color"
712+
/>
698713
<button class="btn btn--ghost btn--small" type="button" data-workflow-save="${escapeHTML(lane.key)}">Save</button>
699714
${lane.isDone ? `<button class="btn btn--ghost btn--small" type="button" disabled aria-disabled="true" title="Done lane cannot be deleted">Delete</button>` : `<button class="btn btn--danger btn--small" type="button" data-workflow-delete="${escapeHTML(lane.key)}" ${canDeleteAnyLane ? "" : `disabled aria-disabled="true" title="Workflow must keep at least 2 lanes"`}>Delete</button>`}
700715
</div>
@@ -729,30 +744,34 @@ async function addWorkflowLane(name: string): Promise<void> {
729744
}
730745
}
731746

732-
async function renameWorkflowLaneLabel(key: string, name: string): Promise<void> {
747+
async function updateWorkflowLane(key: string, payload: { name: string; color: string }): Promise<void> {
733748
const slug = getSlug();
734749
if (!slug) {
735750
showToast("No project available");
736751
return;
737752
}
738-
const trimmed = name.trim();
753+
const trimmed = payload.name.trim();
739754
if (!trimmed) {
740755
showToast("Lane name is required");
741756
return;
742757
}
743-
const currentName = getBoard()?.columnOrder?.find((lane) => lane.key === key)?.name?.trim();
744-
if (currentName === trimmed) return;
758+
const color = payload.color.trim();
759+
const lane = getBoard()?.columnOrder?.find((l) => l.key === key);
760+
const prevColor = (lane?.color ?? DEFAULT_WORKFLOW_LANE_COLOR).toLowerCase();
761+
if (trimmed === lane?.name?.trim() && color.toLowerCase() === prevColor) {
762+
return;
763+
}
745764
try {
746765
recordLocalMutation();
747766
await apiFetch(`/api/board/${slug}/workflow/${encodeURIComponent(key)}`, {
748767
method: "PATCH",
749-
body: JSON.stringify({ name: trimmed }),
768+
body: JSON.stringify({ name: trimmed, color }),
750769
});
751770
await invalidateBoard(slug, getTag(), getSearch(), getSprintIdFromUrl());
752771
await renderSettingsModal();
753-
showToast("Lane label updated");
772+
showToast("Lane updated");
754773
} catch (err: any) {
755-
showToast(err.message || "Failed to update lane label");
774+
showToast(err.message || "Failed to update lane");
756775
}
757776
}
758777

@@ -1440,16 +1459,18 @@ export async function renderSettingsModal(options?: { skipProfileRefetch?: boole
14401459
addLane();
14411460
}, { signal });
14421461
}
1443-
const bindRename = (key: string) => {
1444-
const input = document.querySelector(`[data-workflow-name="${key}"]`) as HTMLInputElement | null;
1445-
if (!input) return;
1446-
renameWorkflowLaneLabel(key, input.value);
1462+
const saveWorkflowLane = (key: string) => {
1463+
const row = document.querySelector(`[data-workflow-key="${key}"]`) as HTMLElement | null;
1464+
const nameInput = row?.querySelector("[data-workflow-name]") as HTMLInputElement | null;
1465+
const colorInput = row?.querySelector("[data-workflow-color]") as HTMLInputElement | null;
1466+
if (!nameInput || !colorInput) return;
1467+
updateWorkflowLane(key, { name: nameInput.value, color: colorInput.value });
14471468
};
14481469
document.querySelectorAll("[data-workflow-save]").forEach((btn) => {
14491470
btn.addEventListener("click", () => {
14501471
const key = (btn as HTMLElement).getAttribute("data-workflow-save");
14511472
if (!key) return;
1452-
bindRename(key);
1473+
saveWorkflowLane(key);
14531474
}, { signal });
14541475
});
14551476
document.querySelectorAll("[data-workflow-name]").forEach((inputEl) => {
@@ -1458,7 +1479,16 @@ export async function renderSettingsModal(options?: { skipProfileRefetch?: boole
14581479
e.preventDefault();
14591480
const key = (inputEl as HTMLElement).getAttribute("data-workflow-name");
14601481
if (!key) return;
1461-
bindRename(key);
1482+
saveWorkflowLane(key);
1483+
}, { signal });
1484+
});
1485+
document.querySelectorAll("[data-workflow-color]").forEach((colorEl) => {
1486+
colorEl.addEventListener("keydown", (e) => {
1487+
if ((e as KeyboardEvent).key !== "Enter") return;
1488+
e.preventDefault();
1489+
const key = (colorEl as HTMLElement).getAttribute("data-workflow-color");
1490+
if (!key) return;
1491+
saveWorkflowLane(key);
14621492
}, { signal });
14631493
});
14641494
document.querySelectorAll("[data-workflow-delete]").forEach((btn) => {

0 commit comments

Comments
 (0)