- {child.name}
- {child.name}
+
{child.status}
+ {/* A branch of its own, so its figures cover more than the
+ one project named on this row (#1264). */}
+ {child.descendant_count > 0 && (
+
+
+ {child.descendant_count}
+
+ )}
+
+ {/* Each row carries its own branch's roll-up, so the rows add
+ up to the card above minus this project's own prints. */}
+
{project.description}
@@ -890,6 +943,96 @@ export function ProjectsPage() {
queryFn: () => api.getProjects(statusFilter === 'all' ? undefined : statusFilter),
});
+ // Parent names come from the unfiltered list, so a sub-project still says
+ // what it belongs to when the status filter has hidden its parent (#1264).
+ // Same query key the parent picker uses — one request shared between them,
+ // and the same one as above whenever the filter is 'all'.
+ const { data: allProjects } = useQuery({
+ queryKey: ['projects', undefined],
+ queryFn: () => api.getProjects(),
+ });
+ const namesById = new Map((allProjects || []).map((p) => [p.id, p.name]));
+
+ // A sub-project is drawn inside its parent's group rather than as another
+ // card somewhere in the grid — two cards that belong together are not
+ // something a caption can convey when they sit columns apart (#1264).
+ const visible = projects || [];
+ const visibleIds = new Set(visible.map((p) => p.id));
+ const childrenByParent = new Map();
+ for (const project of visible) {
+ // Parent hidden by the status filter: the child has nothing to nest under
+ // here, so it stays a top-level card and keeps its "part of" caption.
+ if (project.parent_id !== null && visibleIds.has(project.parent_id)) {
+ const siblings = childrenByParent.get(project.parent_id) || [];
+ siblings.push(project);
+ childrenByParent.set(project.parent_id, siblings);
+ }
+ }
+ // Everything drawn at the top of the grid, in order. Roots first, then any
+ // project the roots cannot reach: a database written before the API refused
+ // A -> B -> A can still hold a cycle, and a cycle has no root, so filtering
+ // on "has no visible parent" alone would drop every project in it off the
+ // page. Each entry point marks its whole branch as drawn, so nothing appears
+ // twice either.
+ const drawn = new Set();
+ const markDrawn = (id: number) => {
+ if (drawn.has(id)) return;
+ drawn.add(id);
+ for (const child of childrenByParent.get(id) || []) markDrawn(child.id);
+ };
+ const topLevel: ProjectListItem[] = [];
+ for (const project of visible) {
+ if (project.parent_id !== null && visibleIds.has(project.parent_id)) continue;
+ topLevel.push(project);
+ markDrawn(project.id);
+ }
+ for (const project of visible) {
+ if (drawn.has(project.id)) continue;
+ topLevel.push(project);
+ markDrawn(project.id);
+ }
+
+ const renderProjectTree = (project: ProjectListItem, depth: number, seen: Set) => {
+ const card = (
+ handleClick(project)}
+ onEdit={() => handleEdit(project)}
+ onDelete={() => handleDeleteClick(project.id)}
+ hasPermission={hasPermission}
+ t={t}
+ />
+ );
+
+ // Drop any child that is already an ancestor on this path. A database
+ // written before the API refused A -> B -> A can still hold a cycle, and
+ // following one would draw the same project over and over.
+ const descended = new Set(seen).add(project.id);
+ const children = (childrenByParent.get(project.id) || []).filter((c) => !descended.has(c.id));
+ if (children.length === 0) return
)}
diff --git a/frontend/src/utils/projectTree.ts b/frontend/src/utils/projectTree.ts
new file mode 100644
index 000000000..381ffdaf9
--- /dev/null
+++ b/frontend/src/utils/projectTree.ts
@@ -0,0 +1,30 @@
+import type { ProjectListItem } from '../api/client';
+
+/**
+ * Projects that may legally become `projectId`'s parent (#1264).
+ *
+ * Its own descendants are excluded as well as itself: nesting a project under
+ * something already beneath it makes a cycle, which the API rejects anyway, so
+ * offering it would only produce an error the user cannot act on. Walked from
+ * the flat list rather than fetched, since every row carries its `parent_id`.
+ */
+export function eligibleParents(
+ projects: ProjectListItem[],
+ projectId: number | undefined,
+): ProjectListItem[] {
+ if (projectId === undefined) return projects;
+ const blocked = new Set([projectId]);
+ // Repeat until nothing new is blocked: the list is in no particular order, so
+ // a grandchild can appear before its parent has been blocked.
+ let grew = true;
+ while (grew) {
+ grew = false;
+ for (const candidate of projects) {
+ if (candidate.parent_id !== null && blocked.has(candidate.parent_id) && !blocked.has(candidate.id)) {
+ blocked.add(candidate.id);
+ grew = true;
+ }
+ }
+ }
+ return projects.filter((p) => !blocked.has(p.id));
+}