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:
liuhao1024
2026-09-18 12:28:46 +08:00
committed by Teknium
parent 63e4e040bc
commit bc3da64fe8
2 changed files with 108 additions and 4 deletions

View File

@@ -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" },

View File

@@ -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"]