generated from coulomb/repo-seed
Implement dashboard UI improvements
This commit is contained in:
@@ -401,12 +401,18 @@ export function buildEntityTable(rows, columns, onRowClick) {
|
||||
for (const col of columns) {
|
||||
const td = document.createElement("td");
|
||||
const raw = col.key ? row[col.key] : null;
|
||||
const text = col.render ? col.render(row) : (raw ?? "—");
|
||||
const textStr = String(text ?? "—");
|
||||
td.textContent = textStr;
|
||||
const rendered = col.render ? col.render(row) : (raw ?? "—");
|
||||
let textStr = "";
|
||||
if (rendered instanceof Node) {
|
||||
td.append(rendered);
|
||||
textStr = td.textContent ?? "";
|
||||
} else {
|
||||
textStr = String(rendered ?? "—");
|
||||
td.textContent = textStr;
|
||||
}
|
||||
if (col.cls) td.className = col.cls;
|
||||
// Native tooltip so full value shows on hover (skip placeholder "—")
|
||||
if (textStr && textStr !== "—") td.title = textStr;
|
||||
if (!(rendered instanceof Node) && textStr && textStr !== "—") td.title = textStr;
|
||||
tr.append(td);
|
||||
}
|
||||
tbody.append(tr);
|
||||
|
||||
160
dashboard/src/components/status-control.js
Normal file
160
dashboard/src/components/status-control.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import {apiFetch} from "./config.js";
|
||||
import {WORKSTREAM_STATUSES} from "./workplan-status.js";
|
||||
|
||||
const STYLE_ID = "status-control-styles";
|
||||
|
||||
export const TASK_STATUSES = ["todo", "in_progress", "blocked", "done", "cancelled"];
|
||||
export {WORKSTREAM_STATUSES};
|
||||
|
||||
function ensureStyles() {
|
||||
if (typeof document === "undefined" || document.getElementById(STYLE_ID)) return;
|
||||
const style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = `
|
||||
.status-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
max-width: 100%;
|
||||
}
|
||||
.status-control-select {
|
||||
min-width: 8.5rem;
|
||||
max-width: 100%;
|
||||
height: 1.85rem;
|
||||
border: 1px solid var(--theme-foreground-faint, #d1d5db);
|
||||
border-radius: 6px;
|
||||
background: var(--theme-background, #fff);
|
||||
color: var(--theme-foreground, #111);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.18rem 0.45rem;
|
||||
}
|
||||
.status-control-select:disabled {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.status-control-message {
|
||||
min-width: 3.5rem;
|
||||
font-size: 0.72rem;
|
||||
color: var(--theme-foreground-muted, #6b7280);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-control-message.status-control-error {
|
||||
color: #dc2626;
|
||||
}
|
||||
.status-control-message.status-control-ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
function labelForStatus(status) {
|
||||
return String(status ?? "").replace(/_/g, " ");
|
||||
}
|
||||
|
||||
function endpointFor(type, id) {
|
||||
if (type === "task") return `/tasks/${id}`;
|
||||
if (type === "workstream") return `/workstreams/${id}`;
|
||||
throw new Error(`Unsupported status-control type: ${type}`);
|
||||
}
|
||||
|
||||
async function readError(response) {
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (Array.isArray(body?.detail)) return body.detail.map(d => d.msg ?? String(d)).join("; ");
|
||||
return body?.detail ?? `HTTP ${response.status}`;
|
||||
} catch {
|
||||
return `HTTP ${response.status}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function statusControl({
|
||||
entity,
|
||||
type,
|
||||
statuses = type === "task" ? TASK_STATUSES : WORKSTREAM_STATUSES,
|
||||
onSaved = null,
|
||||
} = {}) {
|
||||
ensureStyles();
|
||||
|
||||
let currentStatus = entity?.status ?? "";
|
||||
const root = document.createElement("span");
|
||||
root.className = "status-control";
|
||||
root.addEventListener("click", event => event.stopPropagation());
|
||||
root.addEventListener("mousedown", event => event.stopPropagation());
|
||||
|
||||
const select = document.createElement("select");
|
||||
select.className = "status-control-select";
|
||||
select.setAttribute("aria-label", "Status");
|
||||
for (const status of statuses) {
|
||||
const option = document.createElement("option");
|
||||
option.value = status;
|
||||
option.textContent = labelForStatus(status);
|
||||
select.append(option);
|
||||
}
|
||||
select.value = currentStatus;
|
||||
|
||||
const message = document.createElement("span");
|
||||
message.className = "status-control-message";
|
||||
|
||||
function setMessage(text, kind = "") {
|
||||
message.textContent = text;
|
||||
message.classList.toggle("status-control-error", kind === "error");
|
||||
message.classList.toggle("status-control-ok", kind === "ok");
|
||||
}
|
||||
|
||||
select.addEventListener("change", async () => {
|
||||
const nextStatus = select.value;
|
||||
if (nextStatus === currentStatus) return;
|
||||
|
||||
const payload = {status: nextStatus};
|
||||
if (type === "task" && nextStatus === "blocked") {
|
||||
const existingReason = entity?.blocking_reason ?? "";
|
||||
const reason = existingReason || window.prompt("Blocking reason required for blocked tasks:");
|
||||
if (!reason) {
|
||||
select.value = currentStatus;
|
||||
setMessage("unchanged");
|
||||
return;
|
||||
}
|
||||
payload.blocking_reason = reason;
|
||||
}
|
||||
|
||||
if (type === "task" && nextStatus === "done" && currentStatus !== "done") {
|
||||
const ok = window.confirm(
|
||||
"Mark this task done? Without token fields, State Hub records a heuristic token event."
|
||||
);
|
||||
if (!ok) {
|
||||
select.value = currentStatus;
|
||||
setMessage("unchanged");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
select.disabled = true;
|
||||
setMessage("saving");
|
||||
try {
|
||||
const response = await apiFetch(endpointFor(type, entity.id), {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(await readError(response));
|
||||
const updated = await response.json();
|
||||
Object.assign(entity, updated);
|
||||
currentStatus = updated.status;
|
||||
select.value = currentStatus;
|
||||
setMessage("saved", "ok");
|
||||
if (typeof onSaved === "function") onSaved(updated);
|
||||
setTimeout(() => {
|
||||
if (message.textContent === "saved") setMessage("");
|
||||
}, 2200);
|
||||
} catch (error) {
|
||||
select.value = currentStatus;
|
||||
setMessage(error?.message ?? "save failed", "error");
|
||||
} finally {
|
||||
select.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
root.append(select, message);
|
||||
return root;
|
||||
}
|
||||
Reference in New Issue
Block a user