fix(kanban): expose the board's project binding in the dashboard dialogs
The REST API already accepts project_id on board create/update and
GET /boards annotates project_id/project_name, but the dashboard UI
never wired any of it: a board's project binding could only be set
through raw API calls. Add a project selector to the New board and
Board settings dialogs, populated from GET /projects, mirroring the
existing default_workdir wiring. Settings PATCHes send project_id
unconditionally when the selector rendered ("" clears the binding,
server-validated); when the projects store is unreachable the field
is omitted so saving unrelated settings cannot wipe a binding.
Fixes #114652
This commit is contained in:
78
plugins/kanban/dashboard/dist/index.js
vendored
78
plugins/kanban/dashboard/dist/index.js
vendored
@@ -2169,6 +2169,24 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Live (non-archived) Hermes projects available for board scoping,
|
||||
// fetched from GET /projects on mount. On failure the list stays empty
|
||||
// and both dialogs omit the project_id field from their payloads, so a
|
||||
// projects-store hiccup can never clear an existing binding.
|
||||
function useBoardProjects() {
|
||||
const [projects, setProjects] = useState([]);
|
||||
useEffect(function () {
|
||||
let cancelled = false;
|
||||
SDK.fetchJSON(`${API}/projects`)
|
||||
.then(function (res) {
|
||||
if (!cancelled) setProjects((res && res.projects) || []);
|
||||
})
|
||||
.catch(function () { /* optional field; keep the list empty */ });
|
||||
return function () { cancelled = true; };
|
||||
}, []);
|
||||
return projects;
|
||||
}
|
||||
|
||||
function NewBoardDialog(props) {
|
||||
const { t } = useI18n();
|
||||
const [slug, setSlug] = useState("");
|
||||
@@ -2176,6 +2194,8 @@
|
||||
const [description, setDescription] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
const [projectDirectory, setProjectDirectory] = useState("");
|
||||
const projects = useBoardProjects();
|
||||
const [projectId, setProjectId] = useState("");
|
||||
const [switchTo, setSwitchTo] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
@@ -2201,6 +2221,9 @@
|
||||
description: description.trim() || undefined,
|
||||
icon: icon.trim() || undefined,
|
||||
default_workdir: projectDirectory.trim() || undefined,
|
||||
// Only send the binding when the selector was actually rendered
|
||||
// (projects loaded) and one was picked.
|
||||
project_id: (projects.length && projectId) || undefined,
|
||||
switch: switchTo,
|
||||
}).catch(function (e) {
|
||||
setErr(String(e && e.message ? e.message : e));
|
||||
@@ -2277,6 +2300,25 @@
|
||||
tx(t, "projectDirectoryExplanation",
|
||||
"Sets the default location for task files so project output is preserved.")),
|
||||
),
|
||||
projects.length ? h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, tx(t, "boardProject", "Project"), " ",
|
||||
h("span", { className: "text-muted-foreground" },
|
||||
tx(t, "boardProjectHint", "(optional)"))),
|
||||
h(Select, Object.assign({
|
||||
value: projectId,
|
||||
className: "h-8",
|
||||
}, selectChangeHandler(setProjectId)),
|
||||
h(SelectOption, { value: "" },
|
||||
tx(t, "boardProjectNone", "No project binding")),
|
||||
projects.map(function (p) {
|
||||
return h(SelectOption, { key: p.id, value: p.id },
|
||||
p.name || p.slug || p.id);
|
||||
}),
|
||||
),
|
||||
h("div", { className: "text-xs text-muted-foreground" },
|
||||
tx(t, "boardProjectExplanation",
|
||||
"Tasks created on this board inherit the bound project.")),
|
||||
) : null,
|
||||
h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" }, tx(t, "icon", "Icon"), " ",
|
||||
h("span", { className: "text-muted-foreground" },
|
||||
@@ -2314,16 +2356,19 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Board settings dialog — edit display name, description, and the
|
||||
// board-level default project directory (default_workdir). The workdir
|
||||
// is the board-level setting every new task's workspace kind/path is
|
||||
// seeded from; task-level values in the create dialog override it.
|
||||
// Board settings dialog — edit display name, description, the
|
||||
// board-level default project directory (default_workdir), and the
|
||||
// board's project binding (project_id). The workdir is the board-level
|
||||
// setting every new task's workspace kind/path is seeded from;
|
||||
// task-level values in the create dialog override it.
|
||||
function BoardSettingsDialog(props) {
|
||||
const { t } = useI18n();
|
||||
const b = props.board || {};
|
||||
const [name, setName] = useState(b.name || "");
|
||||
const [description, setDescription] = useState(b.description || "");
|
||||
const [projectDirectory, setProjectDirectory] = useState(b.default_workdir || "");
|
||||
const projects = useBoardProjects();
|
||||
const [projectId, setProjectId] = useState(b.project_id || "");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
@@ -2333,10 +2378,15 @@
|
||||
setErr(null);
|
||||
// Send default_workdir unconditionally: "" clears it on the server,
|
||||
// a path sets it (validated server-side: absolute + existing dir).
|
||||
// project_id mirrors that only when the selector was rendered
|
||||
// (projects loaded): "" clears the binding, an id scopes the board.
|
||||
// When the projects store is unreachable the field is omitted so
|
||||
// saving unrelated settings never wipes an existing binding.
|
||||
props.onSave({
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
default_workdir: projectDirectory.trim(),
|
||||
project_id: projects.length ? projectId : undefined,
|
||||
}).catch(function (e) {
|
||||
setErr(parseApiErrorMessage(e));
|
||||
setSubmitting(false);
|
||||
@@ -2391,6 +2441,26 @@
|
||||
tx(t, "projectDirectoryOverrideHint",
|
||||
"New tasks inherit this as their workspace default; each task can still override it in the create dialog.")),
|
||||
),
|
||||
projects.length ? h("div", { className: "flex flex-col gap-1" },
|
||||
h(Label, { className: "text-xs" },
|
||||
tx(t, "boardProject", "Project")),
|
||||
h(Select, Object.assign({
|
||||
value: projectId,
|
||||
className: "h-8",
|
||||
}, selectChangeHandler(setProjectId)),
|
||||
h(SelectOption, { value: "" },
|
||||
tx(t, "boardProjectClear", "No binding (clears on save)")),
|
||||
projects.map(function (p) {
|
||||
return h(SelectOption, {
|
||||
key: p.id,
|
||||
value: p.id,
|
||||
}, p.name || p.slug || p.id);
|
||||
}),
|
||||
),
|
||||
h("div", { className: "text-xs text-muted-foreground" },
|
||||
tx(t, "boardProjectSettingsExplanation",
|
||||
"Bound project tasks inherit the project. Select “No binding” to clear it.")),
|
||||
) : null,
|
||||
),
|
||||
err ? h("div", { className: "text-xs text-destructive mt-2" }, err) : null,
|
||||
h("div", { className: "hermes-kanban-dialog-actions" },
|
||||
|
||||
@@ -907,6 +907,40 @@ def test_dashboard_dependency_selects_use_value_change_handler():
|
||||
assert child_select in bundle
|
||||
|
||||
|
||||
def test_dashboard_board_project_binding_is_exposed_in_ui():
|
||||
"""Regression for #114652: the board create/settings dialogs must
|
||||
expose the board↔project binding the REST API already supports
|
||||
(POST/PATCH ``project_id``, validated against the projects store).
|
||||
|
||||
The bundle has no build step, so the wiring is pinned textually, in
|
||||
the same style as the dependency-select regression above:
|
||||
1. both dialogs fetch the live project list via GET /projects;
|
||||
2. the selectors land on React state through the shared
|
||||
``selectChangeHandler`` helper (the exact bug class salvaged from
|
||||
#20019: value-only selects that never update state);
|
||||
3. create sends ``project_id`` only when the selector rendered and a
|
||||
project was picked, while settings PATCHes mirror
|
||||
``default_workdir`` — send unconditionally when the selector
|
||||
rendered, where ``""`` clears the binding — and omit the field
|
||||
when the projects store is unreachable so saving unrelated
|
||||
settings can never wipe an existing binding.
|
||||
"""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
bundle = (
|
||||
repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js"
|
||||
).read_text()
|
||||
|
||||
assert "function useBoardProjects()" in bundle
|
||||
assert "`${API}/projects`" in bundle
|
||||
assert "selectChangeHandler(setProjectId)" in bundle
|
||||
assert "useState(b.project_id || \"\")" in bundle
|
||||
|
||||
create_payload = "project_id: (projects.length && projectId) || undefined,"
|
||||
patch_payload = "project_id: projects.length ? projectId : undefined,"
|
||||
assert create_payload in bundle
|
||||
assert patch_payload in bundle
|
||||
|
||||
|
||||
def test_bulk_archive(client):
|
||||
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
||||
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
|
||||
|
||||
Reference in New Issue
Block a user