diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index dc582a7660..7f88456df5 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -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" }, diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index def9d30688..c59b419275 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -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"]