Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions internal/httpapi/routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -1115,7 +1115,7 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request, rest []stri
return
}

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

var in struct {
Name string `json:"name"`
Name string `json:"name"`
Color string `json:"color"`
}
if err := readJSON(w, r, s.maxBody, &in); err != nil {
return
}
in.Name = strings.TrimSpace(in.Name)
in.Color = strings.TrimSpace(in.Color)
if in.Name == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "name required", map[string]any{"field": "name"})
return
Expand All @@ -1149,11 +1151,19 @@ func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request, rest []stri
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "invalid workflow column name", map[string]any{"field": "name"})
return
}
if err := s.store.UpdateWorkflowColumnName(ctx, project.ID, columnKey, in.Name); err != nil {
if in.Color == "" {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "color required", map[string]any{"field": "color"})
return
}
if !store.ValidWorkflowColumnColor(in.Color) {
writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", "invalid workflow column color", map[string]any{"field": "color"})
return
}
if err := s.store.UpdateWorkflowColumn(ctx, project.ID, columnKey, in.Name, in.Color); err != nil {
writeStoreErr(w, err, true)
return
}
s.emitRefreshNeeded(project.ID, "workflow_column_renamed")
s.emitRefreshNeeded(project.ID, "workflow_column_updated")
w.WriteHeader(http.StatusNoContent)
return
}
Expand Down
2 changes: 1 addition & 1 deletion internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ type storeAPI interface {
UpdateProjectDefaultSprintWeeks(ctx context.Context, projectID int64, userID int64, weeks int) error
AddWorkflowColumn(ctx context.Context, projectID int64, name string) (store.WorkflowColumn, error)
DeleteWorkflowColumn(ctx context.Context, projectID int64, key string) error
UpdateWorkflowColumnName(ctx context.Context, projectID int64, key, newName string) error
UpdateWorkflowColumn(ctx context.Context, projectID int64, key, name, color string) error
GetProjectRole(ctx context.Context, projectID int64, userID int64) (store.ProjectRole, error)
CheckProjectRole(ctx context.Context, projectID int64, userID int64, requiredRole store.ProjectRole) error
ListProjectMembers(ctx context.Context, projectID int64, userID int64) ([]store.ProjectMember, error)
Expand Down
37 changes: 26 additions & 11 deletions internal/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ func TestRenameLane_RequiresMaintainer(t *testing.T) {
loginUserClient(t, contributorClient, ts.URL, "contrib@example.com", "password123")

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

resp, body := doJSON(t, ownerClient, http.MethodPatch, ts.URL+"/api/board/"+project.Slug+"/workflow/not_a_lane", map[string]any{
"name": "Working",
"name": "Working",
"color": "#10B981",
}, nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d body=%s", resp.StatusCode, string(body))
Expand All @@ -273,23 +275,31 @@ func TestRenameLane_EmptyNameRejected(t *testing.T) {
}{
{
name: "WhitespaceOnly",
body: map[string]any{"name": " "},
body: map[string]any{"name": " ", "color": "#10B981"},
},
{
name: "RejectsColor",
body: map[string]any{"name": "Working", "color": "#123456"},
name: "EmptyColor",
body: map[string]any{"name": "Working", "color": ""},
},
{
name: "MissingColor",
body: map[string]any{"name": "Working"},
},
{
name: "InvalidColor",
body: map[string]any{"name": "Working", "color": "#gggggg"},
},
{
name: "RejectsKey",
body: map[string]any{"name": "Working", "key": "other"},
body: map[string]any{"name": "Working", "color": "#10B981", "key": "other"},
},
{
name: "RejectsIsDone",
body: map[string]any{"name": "Working", "isDone": true},
body: map[string]any{"name": "Working", "color": "#10B981", "isDone": true},
},
{
name: "RejectsPosition",
body: map[string]any{"name": "Working", "position": 1},
body: map[string]any{"name": "Working", "color": "#10B981", "position": 1},
},
}
for _, tc := range tests {
Expand Down Expand Up @@ -317,16 +327,18 @@ func TestRenameLane_BoardAPIReflectsNewName(t *testing.T) {
}

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

var board struct {
ColumnOrder []struct {
Key string `json:"key"`
Name string `json:"name"`
Key string `json:"key"`
Name string `json:"name"`
Color string `json:"color"`
} `json:"columnOrder"`
}
resp, body = doJSON(t, ownerClient, http.MethodGet, ts.URL+"/api/board/"+project.Slug, nil, &board)
Expand All @@ -338,6 +350,9 @@ func TestRenameLane_BoardAPIReflectsNewName(t *testing.T) {
if lane.Name != "Working" {
t.Fatalf("expected lane name %q, got %q", "Working", lane.Name)
}
if lane.Color != "#aabbcc" {
t.Fatalf("expected lane color %q, got %q", "#aabbcc", lane.Color)
}
return
}
}
Expand Down
57 changes: 43 additions & 14 deletions internal/httpapi/web/dist/dialogs/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,11 @@ function msToDateTimeLocalStr(ms) {
const mm = String(d.getMinutes()).padStart(2, "0");
return `${y}-${m}-${day}T${hh}:${mm}`;
}
const DEFAULT_WORKFLOW_LANE_COLOR = "#64748b";
function normalizeWorkflowLaneColorForInput(color) {
const s = color?.trim();
return s && /^#[0-9a-fA-F]{6}$/.test(s) ? s : DEFAULT_WORKFLOW_LANE_COLOR;
}
function renderWorkflowTabContent() {
const board = getBoard();
const workflow = board?.columnOrder ?? [];
Expand All @@ -588,7 +593,7 @@ function renderWorkflowTabContent() {
return `
<div class="settings-section">
<div class="settings-section__title">Workflow</div>
<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>
<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>
<div class="settings-workflow-create" style="display:flex; gap:12px; align-items:flex-end; margin-bottom:16px;">
<label class="field" style="flex:1; min-width:0; margin:0;">
<div class="field__label">New lane name</div>
Expand All @@ -614,6 +619,14 @@ function renderWorkflowTabContent() {
aria-label="Lane label for ${escapeHTML(lane.key)}"
style="flex:1; min-width:0;"
/>
<input
type="color"
class="settings-color-picker"
data-workflow-color="${escapeHTML(lane.key)}"
value="${escapeHTML(normalizeWorkflowLaneColorForInput(lane.color))}"
aria-label="Lane color for ${escapeHTML(lane.key)}"
title="Lane color"
/>
<button class="btn btn--ghost btn--small" type="button" data-workflow-save="${escapeHTML(lane.key)}">Save</button>
${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>`}
</div>
Expand Down Expand Up @@ -647,32 +660,35 @@ async function addWorkflowLane(name) {
showToast(err.message || "Failed to add lane");
}
}
async function renameWorkflowLaneLabel(key, name) {
async function updateWorkflowLane(key, payload) {
const slug = getSlug();
if (!slug) {
showToast("No project available");
return;
}
const trimmed = name.trim();
const trimmed = payload.name.trim();
if (!trimmed) {
showToast("Lane name is required");
return;
}
const currentName = getBoard()?.columnOrder?.find((lane) => lane.key === key)?.name?.trim();
if (currentName === trimmed)
const color = payload.color.trim();
const lane = getBoard()?.columnOrder?.find((l) => l.key === key);
const prevColor = (lane?.color ?? DEFAULT_WORKFLOW_LANE_COLOR).toLowerCase();
if (trimmed === lane?.name?.trim() && color.toLowerCase() === prevColor) {
return;
}
try {
recordLocalMutation();
await apiFetch(`/api/board/${slug}/workflow/${encodeURIComponent(key)}`, {
method: "PATCH",
body: JSON.stringify({ name: trimmed }),
body: JSON.stringify({ name: trimmed, color }),
});
await invalidateBoard(slug, getTag(), getSearch(), getSprintIdFromUrl());
await renderSettingsModal();
showToast("Lane label updated");
showToast("Lane updated");
}
catch (err) {
showToast(err.message || "Failed to update lane label");
showToast(err.message || "Failed to update lane");
}
}
async function deleteWorkflowLane(key) {
Expand Down Expand Up @@ -1341,18 +1357,20 @@ export async function renderSettingsModal(options) {
addLane();
}, { signal });
}
const bindRename = (key) => {
const input = document.querySelector(`[data-workflow-name="${key}"]`);
if (!input)
const saveWorkflowLane = (key) => {
const row = document.querySelector(`[data-workflow-key="${key}"]`);
const nameInput = row?.querySelector("[data-workflow-name]");
const colorInput = row?.querySelector("[data-workflow-color]");
if (!nameInput || !colorInput)
return;
renameWorkflowLaneLabel(key, input.value);
updateWorkflowLane(key, { name: nameInput.value, color: colorInput.value });
};
document.querySelectorAll("[data-workflow-save]").forEach((btn) => {
btn.addEventListener("click", () => {
const key = btn.getAttribute("data-workflow-save");
if (!key)
return;
bindRename(key);
saveWorkflowLane(key);
}, { signal });
});
document.querySelectorAll("[data-workflow-name]").forEach((inputEl) => {
Expand All @@ -1363,7 +1381,18 @@ export async function renderSettingsModal(options) {
const key = inputEl.getAttribute("data-workflow-name");
if (!key)
return;
bindRename(key);
saveWorkflowLane(key);
}, { signal });
});
document.querySelectorAll("[data-workflow-color]").forEach((colorEl) => {
colorEl.addEventListener("keydown", (e) => {
if (e.key !== "Enter")
return;
e.preventDefault();
const key = colorEl.getAttribute("data-workflow-color");
if (!key)
return;
saveWorkflowLane(key);
}, { signal });
});
document.querySelectorAll("[data-workflow-delete]").forEach((btn) => {
Expand Down
58 changes: 44 additions & 14 deletions internal/httpapi/web/modules/dialogs/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,13 @@ function msToDateTimeLocalStr(ms: number): string {
return `${y}-${m}-${day}T${hh}:${mm}`;
}

const DEFAULT_WORKFLOW_LANE_COLOR = "#64748b";

function normalizeWorkflowLaneColorForInput(color: string | undefined | null): string {
const s = color?.trim();
return s && /^#[0-9a-fA-F]{6}$/.test(s) ? s : DEFAULT_WORKFLOW_LANE_COLOR;
}

function renderWorkflowTabContent(): string {
const board = getBoard();
const workflow = board?.columnOrder ?? [];
Expand All @@ -669,7 +676,7 @@ function renderWorkflowTabContent(): string {
return `
<div class="settings-section">
<div class="settings-section__title">Workflow</div>
<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>
<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>
<div class="settings-workflow-create" style="display:flex; gap:12px; align-items:flex-end; margin-bottom:16px;">
<label class="field" style="flex:1; min-width:0; margin:0;">
<div class="field__label">New lane name</div>
Expand All @@ -695,6 +702,14 @@ function renderWorkflowTabContent(): string {
aria-label="Lane label for ${escapeHTML(lane.key)}"
style="flex:1; min-width:0;"
/>
<input
type="color"
class="settings-color-picker"
data-workflow-color="${escapeHTML(lane.key)}"
value="${escapeHTML(normalizeWorkflowLaneColorForInput(lane.color))}"
aria-label="Lane color for ${escapeHTML(lane.key)}"
title="Lane color"
/>
<button class="btn btn--ghost btn--small" type="button" data-workflow-save="${escapeHTML(lane.key)}">Save</button>
${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>`}
</div>
Expand Down Expand Up @@ -729,30 +744,34 @@ async function addWorkflowLane(name: string): Promise<void> {
}
}

async function renameWorkflowLaneLabel(key: string, name: string): Promise<void> {
async function updateWorkflowLane(key: string, payload: { name: string; color: string }): Promise<void> {
const slug = getSlug();
if (!slug) {
showToast("No project available");
return;
}
const trimmed = name.trim();
const trimmed = payload.name.trim();
if (!trimmed) {
showToast("Lane name is required");
return;
}
const currentName = getBoard()?.columnOrder?.find((lane) => lane.key === key)?.name?.trim();
if (currentName === trimmed) return;
const color = payload.color.trim();
const lane = getBoard()?.columnOrder?.find((l) => l.key === key);
const prevColor = (lane?.color ?? DEFAULT_WORKFLOW_LANE_COLOR).toLowerCase();
if (trimmed === lane?.name?.trim() && color.toLowerCase() === prevColor) {
return;
}
try {
recordLocalMutation();
await apiFetch(`/api/board/${slug}/workflow/${encodeURIComponent(key)}`, {
method: "PATCH",
body: JSON.stringify({ name: trimmed }),
body: JSON.stringify({ name: trimmed, color }),
});
await invalidateBoard(slug, getTag(), getSearch(), getSprintIdFromUrl());
await renderSettingsModal();
showToast("Lane label updated");
showToast("Lane updated");
} catch (err: any) {
showToast(err.message || "Failed to update lane label");
showToast(err.message || "Failed to update lane");
}
}

Expand Down Expand Up @@ -1440,16 +1459,18 @@ export async function renderSettingsModal(options?: { skipProfileRefetch?: boole
addLane();
}, { signal });
}
const bindRename = (key: string) => {
const input = document.querySelector(`[data-workflow-name="${key}"]`) as HTMLInputElement | null;
if (!input) return;
renameWorkflowLaneLabel(key, input.value);
const saveWorkflowLane = (key: string) => {
const row = document.querySelector(`[data-workflow-key="${key}"]`) as HTMLElement | null;
const nameInput = row?.querySelector("[data-workflow-name]") as HTMLInputElement | null;
const colorInput = row?.querySelector("[data-workflow-color]") as HTMLInputElement | null;
if (!nameInput || !colorInput) return;
updateWorkflowLane(key, { name: nameInput.value, color: colorInput.value });
};
document.querySelectorAll("[data-workflow-save]").forEach((btn) => {
btn.addEventListener("click", () => {
const key = (btn as HTMLElement).getAttribute("data-workflow-save");
if (!key) return;
bindRename(key);
saveWorkflowLane(key);
}, { signal });
});
document.querySelectorAll("[data-workflow-name]").forEach((inputEl) => {
Expand All @@ -1458,7 +1479,16 @@ export async function renderSettingsModal(options?: { skipProfileRefetch?: boole
e.preventDefault();
const key = (inputEl as HTMLElement).getAttribute("data-workflow-name");
if (!key) return;
bindRename(key);
saveWorkflowLane(key);
}, { signal });
});
document.querySelectorAll("[data-workflow-color]").forEach((colorEl) => {
colorEl.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key !== "Enter") return;
e.preventDefault();
const key = (colorEl as HTMLElement).getAttribute("data-workflow-color");
if (!key) return;
saveWorkflowLane(key);
}, { signal });
});
document.querySelectorAll("[data-workflow-delete]").forEach((btn) => {
Expand Down
Loading
Loading