Skip to content

Commit b52f443

Browse files
fix(ui): persist inbox subtask folds
Co-Authored-By: Paperclip <noreply@paperclip.ing>
1 parent 384e5f6 commit b52f443

4 files changed

Lines changed: 139 additions & 3 deletions

File tree

ui/src/lib/inbox.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
loadInboxIssueColumns,
3636
loadInboxWorkItemGroupBy,
3737
loadCollapsedInboxGroupKeys,
38+
loadCollapsedInboxParentIds,
3839
loadLastInboxTab,
3940
matchesInboxIssueSearch,
4041
normalizeInboxIssueColumns,
@@ -45,6 +46,7 @@ import {
4546
resolveInboxSelectionIndex,
4647
saveInboxFilterPreferences,
4748
saveCollapsedInboxGroupKeys,
49+
saveCollapsedInboxParentIds,
4850
saveInboxIssueColumns,
4951
saveInboxWorkItemGroupBy,
5052
saveLastInboxTab,
@@ -1560,6 +1562,23 @@ describe("inbox helpers", () => {
15601562
expect(loadCollapsedInboxGroupKeys("company-1")).toEqual(new Set());
15611563
});
15621564

1565+
it("persists collapsed inbox parents per company", () => {
1566+
saveCollapsedInboxParentIds("company-1", new Set(["parent-1", "parent-2"]));
1567+
saveCollapsedInboxParentIds("company-2", new Set(["parent-3"]));
1568+
1569+
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set(["parent-1", "parent-2"]));
1570+
expect(loadCollapsedInboxParentIds("company-2")).toEqual(new Set(["parent-3"]));
1571+
1572+
saveCollapsedInboxParentIds("company-1", new Set());
1573+
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
1574+
});
1575+
1576+
it("returns empty collapsed inbox parents for missing or invalid storage", () => {
1577+
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
1578+
localStorage.setItem("paperclip:inbox:collapsed-parents:company-1", JSON.stringify({ nope: true }));
1579+
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
1580+
});
1581+
15631582
it("does not reset workspace grouping before experimental settings have loaded", () => {
15641583
expect(shouldResetInboxWorkspaceGrouping("workspace", false, false)).toBe(false);
15651584
});

ui/src/lib/inbox.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
2626
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
2727
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
2828
export const INBOX_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:inbox:collapsed-groups";
29+
export const INBOX_COLLAPSED_PARENTS_KEY_PREFIX = "paperclip:inbox:collapsed-parents";
2930
export type InboxTab = "mine" | "recent" | "unread" | "blocked" | "all";
3031
export type InboxCategoryFilter =
3132
| "everything"
@@ -187,6 +188,11 @@ function getInboxCollapsedGroupsStorageKey(companyId: string | null | undefined)
187188
return `${INBOX_COLLAPSED_GROUPS_KEY_PREFIX}:${companyId}`;
188189
}
189190

191+
function getInboxCollapsedParentsStorageKey(companyId: string | null | undefined): string | null {
192+
if (!companyId) return null;
193+
return `${INBOX_COLLAPSED_PARENTS_KEY_PREFIX}:${companyId}`;
194+
}
195+
190196
export function loadInboxFilterPreferences(
191197
companyId: string | null | undefined,
192198
): InboxFilterPreferences {
@@ -271,6 +277,36 @@ export function saveCollapsedInboxGroupKeys(
271277
}
272278
}
273279

280+
export function loadCollapsedInboxParentIds(
281+
companyId: string | null | undefined,
282+
): Set<string> {
283+
const storageKey = getInboxCollapsedParentsStorageKey(companyId);
284+
if (!storageKey) return new Set();
285+
286+
try {
287+
const raw = localStorage.getItem(storageKey);
288+
if (!raw) return new Set();
289+
const parsed = JSON.parse(raw);
290+
return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []);
291+
} catch {
292+
return new Set();
293+
}
294+
}
295+
296+
export function saveCollapsedInboxParentIds(
297+
companyId: string | null | undefined,
298+
parentIds: ReadonlySet<string>,
299+
) {
300+
const storageKey = getInboxCollapsedParentsStorageKey(companyId);
301+
if (!storageKey) return;
302+
303+
try {
304+
localStorage.setItem(storageKey, JSON.stringify([...parentIds]));
305+
} catch {
306+
// Ignore localStorage failures.
307+
}
308+
}
309+
274310
export function loadDismissedInboxAlerts(): Set<string> {
275311
try {
276312
const raw = localStorage.getItem(DISMISSED_KEY);

ui/src/pages/Inbox.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,80 @@ describe("Inbox toolbar", () => {
345345
act(() => root.unmount());
346346
});
347347

348+
it("restores folded and unfolded sub-tasks across remounts", async () => {
349+
routerMock.location.pathname = "/inbox/mine";
350+
const storageKey = "paperclip:inbox:collapsed-parents:company-1";
351+
localStorage.removeItem(storageKey);
352+
353+
const parent = createIssue({
354+
id: "parent-issue",
355+
identifier: "PAP-1001",
356+
title: "Parent inbox task",
357+
});
358+
const child = createIssue({
359+
id: "child-issue",
360+
identifier: "PAP-1002",
361+
parentId: parent.id,
362+
title: "Nested inbox task",
363+
});
364+
apiMocks.issuesList.mockResolvedValue([parent, child]);
365+
366+
const mountInbox = async () => {
367+
const queryClient = new QueryClient({
368+
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
369+
});
370+
const root = createRoot(container);
371+
await act(async () => {
372+
root.render(
373+
<QueryClientProvider client={queryClient}>
374+
<Inbox />
375+
</QueryClientProvider>,
376+
);
377+
});
378+
await vi.waitFor(() => {
379+
expect(container.textContent).toContain(parent.title);
380+
});
381+
return root;
382+
};
383+
const parentToggle = () => {
384+
const parentRow = Array.from(container.querySelectorAll("[data-inbox-item]"))
385+
.find((row) => row.textContent?.includes(parent.title));
386+
return parentRow?.querySelector<HTMLButtonElement>('button[data-slot="icon-button"]') ?? null;
387+
};
388+
389+
let root = await mountInbox();
390+
try {
391+
expect(container.textContent).toContain(child.title);
392+
393+
await act(async () => {
394+
parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
395+
});
396+
await vi.waitFor(() => {
397+
expect(container.textContent).not.toContain(child.title);
398+
});
399+
expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([parent.id]);
400+
401+
act(() => root.unmount());
402+
root = await mountInbox();
403+
expect(container.textContent).not.toContain(child.title);
404+
405+
await act(async () => {
406+
parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
407+
});
408+
await vi.waitFor(() => {
409+
expect(container.textContent).toContain(child.title);
410+
});
411+
expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([]);
412+
413+
act(() => root.unmount());
414+
root = await mountInbox();
415+
expect(container.textContent).toContain(child.title);
416+
} finally {
417+
localStorage.removeItem(storageKey);
418+
act(() => root.unmount());
419+
}
420+
});
421+
348422
it("shows blocked toolbar controls on the Blocked tab", async () => {
349423
routerMock.location.pathname = "/inbox/blocked";
350424
const queryClient = new QueryClient({

ui/src/pages/Inbox.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ import {
144144
isInboxEntityDismissed,
145145
isMineInboxTab,
146146
loadCollapsedInboxGroupKeys,
147+
loadCollapsedInboxParentIds,
147148
loadInboxFilterPreferences,
148149
loadInboxIssueColumns,
149150
loadInboxNesting,
@@ -155,6 +156,7 @@ import {
155156
resolveInboxSelectionIndex,
156157
saveInboxFilterPreferences,
157158
saveCollapsedInboxGroupKeys,
159+
saveCollapsedInboxParentIds,
158160
saveInboxIssueColumns,
159161
saveInboxNesting,
160162
saveInboxWorkItemGroupBy,
@@ -801,6 +803,7 @@ export function Inbox() {
801803
previousSelectedCompanyIdRef.current = selectedCompanyId;
802804
setFilterPreferences(loadInboxFilterPreferences(selectedCompanyId));
803805
setCollapsedGroupKeys(loadCollapsedInboxGroupKeys(selectedCompanyId));
806+
setCollapsedInboxParents(loadCollapsedInboxParentIds(selectedCompanyId));
804807
}
805808
}, [selectedCompanyId]);
806809

@@ -1372,7 +1375,9 @@ export function Inbox() {
13721375
return next;
13731376
});
13741377
}, []);
1375-
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
1378+
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(
1379+
() => loadCollapsedInboxParentIds(selectedCompanyId),
1380+
);
13761381
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => loadCollapsedInboxGroupKeys(selectedCompanyId));
13771382
const toggleGroupCollapse = useCallback((groupKey: string) => {
13781383
setCollapsedGroupKeys((prev) => {
@@ -1508,18 +1513,20 @@ export function Inbox() {
15081513
const next = new Set(prev);
15091514
if (next.has(parentId)) next.delete(parentId);
15101515
else next.add(parentId);
1516+
saveCollapsedInboxParentIds(selectedCompanyId, next);
15111517
return next;
15121518
});
1513-
}, []);
1519+
}, [selectedCompanyId]);
15141520
const setInboxParentCollapsed = useCallback((parentId: string, collapsed: boolean) => {
15151521
setCollapsedInboxParents((prev) => {
15161522
if (prev.has(parentId) === collapsed) return prev;
15171523
const next = new Set(prev);
15181524
if (collapsed) next.add(parentId);
15191525
else next.delete(parentId);
1526+
saveCollapsedInboxParentIds(selectedCompanyId, next);
15201527
return next;
15211528
});
1522-
}, []);
1529+
}, [selectedCompanyId]);
15231530

15241531
// Build flat navigation list from visible rows so keyboard traversal respects collapsed groups.
15251532
const flatNavItems = useMemo((): NavEntry[] => {

0 commit comments

Comments
 (0)