Skip to content

Commit dc27853

Browse files
committed
fix: keep tree hierarchies whole when filters or pagination split pages (WI-1317)
The tree built its hierarchy client-side from a flat server-paginated page, so any item whose parent fell outside the loaded page rendered as a stray root row. Toggling the completed filter reshuffled page composition and flipped items between nested and detached rendering. The tree now detects orphaned children and fills the gaps from a new POST /v2/items/batch-ancestors endpoint that resolves many ancestor chains in one recursive CTE under the /items/batch permission and dedupe contract. Fetched chains are merged and expanded so previously visible children stay visible, and pruned once a reload loads the parent itself. Tree page size drops from 250 to 100 to bound the worst case to a single batched request per page change.
1 parent 16e29e6 commit dc27853

10 files changed

Lines changed: 1203 additions & 29 deletions

File tree

api/openapi-v2.json

Lines changed: 833 additions & 0 deletions
Large diffs are not rendered by default.

frontend/src/lib/api/items.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,30 @@ export const items = {
270270
fetchV2Data(`/items/${itemId}/children`, requestOptions),
271271
getAncestors: (itemId, requestOptions = {}) =>
272272
fetchV2Data(`/items/${itemId}/ancestors`, requestOptions),
273+
/**
274+
* Fetch ancestor chains for many items in one (or a few) batch requests.
275+
* Returns an array of { item_id, ancestors } entries — ancestors ordered
276+
* root -> parent and excluding the item itself. Ids the caller can't view or
277+
* that don't exist are silently omitted, matching getMany. Chunked under
278+
* the server's 500-id cap.
279+
*/
280+
getManyAncestors: async (ids = []) => {
281+
const unique = [...new Set(ids)].filter((id) => id != null);
282+
if (unique.length === 0) return [];
283+
const chunks = [];
284+
for (let i = 0; i < unique.length; i += ITEM_BATCH_CHUNK) {
285+
chunks.push(unique.slice(i, i + ITEM_BATCH_CHUNK));
286+
}
287+
const results = await Promise.all(
288+
chunks.map((chunk) =>
289+
fetchV2Data('/items/batch-ancestors', {
290+
method: 'POST',
291+
body: JSON.stringify({ ids: chunk }),
292+
})
293+
)
294+
);
295+
return results.flat();
296+
},
273297
getDescendants: (itemId, maxDepth = null) => {
274298
const params = maxDepth ? `?max_depth=${maxDepth}` : '';
275299
return fetchV2Data(`/items/${itemId}/descendants${params}`);

frontend/src/lib/features/collections/CollectionTree.svelte

Lines changed: 83 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,34 @@
1919
import { formatDate } from '../../utils/dateFormatter.js';
2020
import { moduleSettings } from '../../stores/moduleSettings.js';
2121
import { itemTestCaseLinksStore, workspaceDataStore } from '../../stores/index.js';
22-
import { indexCollectionHierarchy } from './collectionHierarchy.js';
22+
import {
23+
findOrphansByMissingParent,
24+
indexCollectionHierarchy,
25+
mergeAncestorContext,
26+
} from './collectionHierarchy.js';
2327
2428
let { workspaceId, collectionId = null } = $props();
2529
2630
let workspace = $derived(workspaceDataStore.workspace);
27-
let allItems = $state([]);
31+
// Items as loaded from the collection store, plus fetched ancestor context.
32+
let storeItems = $state([]);
33+
// Missing parent id -> ancestor chain (root -> parent). Fills gaps left by
34+
// pagination and the completed filter so hierarchies stay whole.
35+
let ancestorsByParent = $state({});
36+
let ancestorsRequestInFlight = false;
37+
let ancestorsRerunPending = false;
2838
let itemTypes = $derived(workspaceDataStore.itemTypes);
2939
let statuses = $derived(workspaceDataStore.statuses);
3040
let statusCategories = $derived(workspaceDataStore.statusCategories);
3141
let priorities = $derived(workspaceDataStore.priorities);
3242
let loading = $state(true);
3343
let currentCollectionName = $state('Default');
3444
let expandedItems = $state(new Set()); // Track which items are expanded
45+
let allItems = $derived(
46+
[...mergeAncestorContext(storeItems, ancestorsByParent)].sort(
47+
(a, b) => a.level - b.level || a.id - b.id
48+
)
49+
);
3550
let hierarchyIndex = $derived(indexCollectionHierarchy(allItems));
3651
3752
// Pagination state
@@ -63,20 +78,82 @@
6378
await loadData();
6479
});
6580
66-
// Sync items from central store
81+
// Sync items from central store, then reset expansion to the all-roots
82+
// default for the new page. Ancestors fetched later expand themselves in
83+
// syncAncestorContext so their children stay visible.
6784
$effect(() => {
6885
if (!collectionStore.loading && collectionStore.items.length >= 0) {
6986
currentCollectionName = collectionStore.collectionName;
70-
const sorted = [...collectionStore.items].sort((a, b) => a.level - b.level || a.id - b.id);
71-
// untrack to avoid tracking reads of allItems/expandedItems via getRootItems/hasChildren
87+
storeItems = [...collectionStore.items].sort((a, b) => a.level - b.level || a.id - b.id);
7288
untrack(() => {
73-
allItems = sorted;
7489
const rootItems = getRootItems();
7590
expandedItems = new Set(rootItems.filter(i => hasChildren(i.id)).map(i => i.id));
7691
});
7792
}
7893
});
7994
95+
// Load ancestor chains for items whose parent is not on the loaded page so
96+
// filtered or paginated children do not render as stray roots.
97+
$effect(() => {
98+
const items = storeItems;
99+
untrack(() => void syncAncestorContext(items));
100+
});
101+
102+
async function syncAncestorContext(items) {
103+
const orphansByParent = findOrphansByMissingParent(items);
104+
const neededParents = new Set(orphansByParent.keys());
105+
106+
// Drop cached chains whose gap the store has since filled itself.
107+
const kept = {};
108+
let pruned = false;
109+
for (const [parentId, chain] of Object.entries(ancestorsByParent)) {
110+
if (neededParents.has(Number(parentId))) {
111+
kept[parentId] = chain;
112+
} else {
113+
pruned = true;
114+
}
115+
}
116+
if (pruned) ancestorsByParent = kept;
117+
118+
const parentByOrphanId = new Map();
119+
for (const [parentId, orphans] of orphansByParent) {
120+
parentByOrphanId.set(orphans[0].id, parentId);
121+
}
122+
const orphanIds = [...parentByOrphanId.keys()].filter(
123+
(orphanId) => !kept[String(parentByOrphanId.get(orphanId))]
124+
);
125+
if (orphanIds.length === 0) return;
126+
if (ancestorsRequestInFlight) {
127+
// A newer page may need different chains; rerun when the current
128+
// request settles.
129+
ancestorsRerunPending = true;
130+
return;
131+
}
132+
ancestorsRequestInFlight = true;
133+
try {
134+
const entries = await api.items.getManyAncestors(orphanIds);
135+
for (const { item_id: orphanId, ancestors } of entries) {
136+
// The gap may have closed while the request was in flight.
137+
const parentId = parentByOrphanId.get(orphanId);
138+
if (!ancestors?.length || parentId == null || !neededParents.has(parentId)) continue;
139+
ancestorsByParent = { ...ancestorsByParent, [String(parentId)]: ancestors };
140+
// Keep previously visible children visible under their new parents.
141+
for (const ancestor of ancestors) {
142+
expandedItems.add(ancestor.id);
143+
}
144+
}
145+
expandedItems = new Set(expandedItems);
146+
} catch (error) {
147+
console.error('[CollectionTree] Failed to load ancestor context:', error);
148+
} finally {
149+
ancestorsRequestInFlight = false;
150+
}
151+
if (ancestorsRerunPending) {
152+
ancestorsRerunPending = false;
153+
await syncAncestorContext(storeItems);
154+
}
155+
}
156+
80157
async function loadData() {
81158
loading = true;
82159
if (workspaceId) {

frontend/src/lib/features/collections/collectionHierarchy.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,44 @@
1+
/**
2+
* Items whose parent is not part of the loaded set. Pagination and filters
3+
* split hierarchies across pages, so a child can be loaded while its parent
4+
* is not. Returns a map keyed by the missing parent id because one ancestor
5+
* fetch per missing parent covers every orphan below it.
6+
*/
7+
export function findOrphansByMissingParent(items) {
8+
const itemIds = new Set(items.map((item) => item.id));
9+
const orphansByParent = new Map();
10+
for (const item of items) {
11+
if (item.parent_id == null || itemIds.has(item.parent_id)) continue;
12+
const orphans = orphansByParent.get(item.parent_id);
13+
if (orphans) {
14+
orphans.push(item);
15+
} else {
16+
orphansByParent.set(item.parent_id, [item]);
17+
}
18+
}
19+
return orphansByParent;
20+
}
21+
22+
/**
23+
* Adds fetched ancestor chains (root -> parent order) to the loaded items so
24+
* hierarchies render intact. Entries whose parent has since been loaded by the
25+
* regular query are ignored, and items already present are never duplicated.
26+
*/
27+
export function mergeAncestorContext(items, ancestorsByParent) {
28+
if (!ancestorsByParent) return items;
29+
const itemIds = new Set(items.map((item) => item.id));
30+
const merged = [...items];
31+
for (const [parentId, chain] of Object.entries(ancestorsByParent)) {
32+
if (itemIds.has(Number(parentId))) continue;
33+
for (const ancestor of chain ?? []) {
34+
if (itemIds.has(ancestor.id)) continue;
35+
itemIds.add(ancestor.id);
36+
merged.push(ancestor);
37+
}
38+
}
39+
return merged;
40+
}
41+
142
export function indexCollectionHierarchy(items) {
243
const itemIds = new Set(items.map((item) => item.id));
344
const childrenByParent = new Map();

frontend/src/lib/stores/collectionContext.svelte.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ const BACKLOG_VIEWS = new Set(['workspace-backlog', 'collection-backlog']);
3131

3232
const DEFAULT_PAGE_SIZE = 100;
3333
const LIST_INITIAL_PAGE_SIZE = 50;
34+
// Tree pages stay smaller than map/roadmap: every loaded item can require an
35+
// ancestors lookup, so the page bounds the hierarchy-repair fan-out.
36+
const TREE_PAGE_SIZE = 100;
3437
const LARGE_COLLECTION_PAGE_SIZE = 250;
3538
const BOARD_UNFINISHED_PAGE_SIZE = 1000;
3639
const BOARD_UNTHROTTLED_ITEM_COUNT = 1000;
@@ -48,9 +51,8 @@ function appendUniqueItems(existing, incoming) {
4851

4952
function initialItemsPageSize(view) {
5053
if (view === 'workspace-list' || view === 'collection-list') return LIST_INITIAL_PAGE_SIZE;
54+
if (view === 'workspace-tree' || view === 'collection-tree') return TREE_PAGE_SIZE;
5155
if (
52-
view === 'workspace-tree' ||
53-
view === 'collection-tree' ||
5456
view === 'workspace-map' ||
5557
view === 'collection-map' ||
5658
view === 'workspace-roadmap' ||

internal/repository/item_hierarchy.go

Lines changed: 106 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -224,34 +224,119 @@ func (r *ItemRepository) GetAncestorsForHierarchyContext(ctx context.Context, it
224224
var ancestors []models.Item
225225
for rows.Next() {
226226
var item models.Item
227-
var itemTypeID, assigneeID, creatorID, parentID sql.NullInt64
228-
var customFieldValuesJSON sql.NullString
229-
var workspaceName, workspaceKey, itemTypeName, itemTypeColor, itemTypeIcon sql.NullString
230-
var level int
231-
if err := rows.Scan(
232-
&item.ID, &item.WorkspaceID, &item.WorkspaceItemNumber, &itemTypeID, &item.Title, &item.Description, &item.IsTask,
233-
&assigneeID, &creatorID, &customFieldValuesJSON, &parentID,
234-
&item.CreatedAt, &item.UpdatedAt,
235-
&workspaceName, &workspaceKey, &itemTypeName, &itemTypeColor, &itemTypeIcon, &level,
236-
); err != nil {
227+
if err := scanAncestorItem(rows, &item); err != nil {
237228
return nil, fmt.Errorf("failed to scan ancestor: %w", err)
238229
}
239-
_ = level
240-
_ = itemTypeColor
241-
_ = itemTypeIcon
242-
assignNullableInt(&item.ItemTypeID, itemTypeID)
243-
assignNullableInt(&item.AssigneeID, assigneeID)
244-
assignNullableInt(&item.CreatorID, creatorID)
245-
assignNullableInt(&item.ParentID, parentID)
246-
assignNullableString(&item.WorkspaceName, workspaceName)
247-
assignNullableString(&item.WorkspaceKey, workspaceKey)
248-
assignNullableString(&item.ItemTypeName, itemTypeName)
249-
item.CustomFieldValues = parseCustomFieldsJSON(customFieldValuesJSON)
250230
ancestors = append(ancestors, item)
251231
}
252232
return ancestors, rows.Err()
253233
}
254234

235+
// scanAncestorItem reads one row of the shared ancestor SELECT column list:
236+
// id, workspace_id, workspace_item_number, item_type_id, title, description,
237+
// is_task, assignee_id, creator_id, custom_field_values, parent_id,
238+
// created_at, updated_at, workspace_name, workspace_key, item_type_name,
239+
// item_type_color, item_type_icon, level. Leading targets, when given, precede
240+
// the shared columns (the batch query prefixes each row with its start id).
241+
func scanAncestorItem(rows *sql.Rows, item *models.Item, leading ...any) error {
242+
var itemTypeID, assigneeID, creatorID, parentID sql.NullInt64
243+
var customFieldValuesJSON sql.NullString
244+
var workspaceName, workspaceKey, itemTypeName, itemTypeColor, itemTypeIcon sql.NullString
245+
var level int
246+
dest := make([]any, 0, len(leading)+19)
247+
dest = append(dest, leading...)
248+
dest = append(dest,
249+
&item.ID, &item.WorkspaceID, &item.WorkspaceItemNumber, &itemTypeID, &item.Title, &item.Description, &item.IsTask,
250+
&assigneeID, &creatorID, &customFieldValuesJSON, &parentID,
251+
&item.CreatedAt, &item.UpdatedAt,
252+
&workspaceName, &workspaceKey, &itemTypeName, &itemTypeColor, &itemTypeIcon, &level,
253+
)
254+
if err := rows.Scan(dest...); err != nil {
255+
return err
256+
}
257+
_ = level
258+
_ = itemTypeColor
259+
_ = itemTypeIcon
260+
assignNullableInt(&item.ItemTypeID, itemTypeID)
261+
assignNullableInt(&item.AssigneeID, assigneeID)
262+
assignNullableInt(&item.CreatorID, creatorID)
263+
assignNullableInt(&item.ParentID, parentID)
264+
assignNullableString(&item.WorkspaceName, workspaceName)
265+
assignNullableString(&item.WorkspaceKey, workspaceKey)
266+
assignNullableString(&item.ItemTypeName, itemTypeName)
267+
item.CustomFieldValues = parseCustomFieldsJSON(customFieldValuesJSON)
268+
return nil
269+
}
270+
271+
// GetAncestorsForItemsContext resolves ancestor chains for many items in one
272+
// recursive walk (maxDepth caps each chain). Chains exclude the items
273+
// themselves and are ordered root -> parent; every requested id gets an entry,
274+
// empty when the item has no ancestors.
275+
func (r *ItemRepository) GetAncestorsForItemsContext(ctx context.Context, itemIDs []int, maxDepth int) (map[int][]models.Item, error) {
276+
result := make(map[int][]models.Item, len(itemIDs))
277+
if len(itemIDs) == 0 {
278+
return result, nil
279+
}
280+
if maxDepth <= 0 || maxDepth > maxItemHierarchyDepth {
281+
maxDepth = maxItemHierarchyDepth
282+
}
283+
placeholders := strings.Repeat("?,", len(itemIDs))
284+
placeholders = placeholders[:len(placeholders)-1]
285+
args := make([]any, 0, len(itemIDs)+1)
286+
for _, id := range itemIDs {
287+
args = append(args, id)
288+
}
289+
args = append(args, maxDepth)
290+
rows, err := r.db.QueryContext(ctx, `
291+
WITH RECURSIVE ancestors AS (
292+
SELECT i.id AS start_id, i.id, i.workspace_id, i.workspace_item_number, i.item_type_id, i.title, i.description, i.is_task,
293+
i.assignee_id, i.creator_id, i.custom_field_values, i.parent_id,
294+
i.created_at, i.updated_at,
295+
w.name as workspace_name, w.key as workspace_key, it.name as item_type_name, it.color as item_type_color, it.icon as item_type_icon,
296+
0 as level, it.hierarchy_level
297+
FROM items i
298+
JOIN workspaces w ON i.workspace_id = w.id
299+
LEFT JOIN item_types it ON i.item_type_id = it.id
300+
WHERE i.id IN (`+placeholders+`)
301+
302+
UNION ALL
303+
304+
SELECT a.start_id, p.id, p.workspace_id, p.workspace_item_number, p.item_type_id, p.title, p.description, p.is_task,
305+
p.assignee_id, p.creator_id, p.custom_field_values, p.parent_id,
306+
p.created_at, p.updated_at,
307+
w.name as workspace_name, w.key as workspace_key, it.name as item_type_name, it.color as item_type_color, it.icon as item_type_icon,
308+
a.level + 1 as level, it.hierarchy_level
309+
FROM items p
310+
JOIN workspaces w ON p.workspace_id = w.id
311+
LEFT JOIN item_types it ON p.item_type_id = it.id
312+
JOIN ancestors a ON p.id = a.parent_id
313+
WHERE a.level < ?
314+
AND COALESCE(a.hierarchy_level, -999) != 0
315+
)
316+
SELECT start_id, id, workspace_id, workspace_item_number, item_type_id, title, description, is_task,
317+
assignee_id, creator_id, custom_field_values, parent_id,
318+
created_at, updated_at,
319+
workspace_name, workspace_key, item_type_name, item_type_color, item_type_icon, level
320+
FROM ancestors
321+
WHERE level > 0
322+
ORDER BY start_id, level DESC
323+
`, args...)
324+
if err != nil {
325+
return nil, fmt.Errorf("failed to query ancestors for items: %w", err)
326+
}
327+
defer func() { _ = rows.Close() }()
328+
329+
for rows.Next() {
330+
var startID int
331+
var item models.Item
332+
if err := scanAncestorItem(rows, &item, &startID); err != nil {
333+
return nil, fmt.Errorf("failed to scan ancestor: %w", err)
334+
}
335+
result[startID] = append(result[startID], item)
336+
}
337+
return result, rows.Err()
338+
}
339+
255340
// GetRootItems returns all root items (no parent) for a workspace
256341
func (r *ItemRepository) GetRootItems(workspaceID int) ([]*models.Item, error) {
257342
rows, err := r.db.Query(`

internal/restapi/v2/contract-metadata.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4413,6 +4413,25 @@
44134413
]
44144414
}
44154415
},
4416+
"/items/batch-ancestors": {
4417+
"post": {
4418+
"description": "Get item ancestors batch. The server validates resource ownership and command preconditions before persisting changes. Unknown JSON fields are rejected when the operation accepts a body.",
4419+
"parameters": [],
4420+
"responses": {
4421+
"400": {},
4422+
"401": {},
4423+
"403": {},
4424+
"413": {},
4425+
"415": {},
4426+
"429": {},
4427+
"500": {}
4428+
},
4429+
"summary": "Get item ancestors batch",
4430+
"tags": [
4431+
"Work items"
4432+
]
4433+
}
4434+
},
44164435
"/items/bulk-patch": {
44174436
"post": {
44184437
"description": "Bulk patch items. The server validates resource ownership and command preconditions before persisting changes. Unknown JSON fields are rejected when the operation accepts a body.",

0 commit comments

Comments
 (0)