Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions ui/src/lib/inbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
loadInboxIssueColumns,
loadInboxWorkItemGroupBy,
loadCollapsedInboxGroupKeys,
loadCollapsedInboxParentIds,
loadLastInboxTab,
matchesInboxIssueSearch,
normalizeInboxIssueColumns,
Expand All @@ -45,6 +46,7 @@ import {
resolveInboxSelectionIndex,
saveInboxFilterPreferences,
saveCollapsedInboxGroupKeys,
saveCollapsedInboxParentIds,
saveInboxIssueColumns,
saveInboxWorkItemGroupBy,
saveLastInboxTab,
Expand Down Expand Up @@ -1560,6 +1562,23 @@ describe("inbox helpers", () => {
expect(loadCollapsedInboxGroupKeys("company-1")).toEqual(new Set());
});

it("persists collapsed inbox parents per company", () => {
saveCollapsedInboxParentIds("company-1", new Set(["parent-1", "parent-2"]));
saveCollapsedInboxParentIds("company-2", new Set(["parent-3"]));

expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set(["parent-1", "parent-2"]));
expect(loadCollapsedInboxParentIds("company-2")).toEqual(new Set(["parent-3"]));

saveCollapsedInboxParentIds("company-1", new Set());
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
});

it("returns empty collapsed inbox parents for missing or invalid storage", () => {
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
localStorage.setItem("paperclip:inbox:collapsed-parents:company-1", JSON.stringify({ nope: true }));
expect(loadCollapsedInboxParentIds("company-1")).toEqual(new Set());
});

it("does not reset workspace grouping before experimental settings have loaded", () => {
expect(shouldResetInboxWorkspaceGrouping("workspace", false, false)).toBe(false);
});
Expand Down
36 changes: 36 additions & 0 deletions ui/src/lib/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const INBOX_NESTING_KEY = "paperclip:inbox:nesting";
export const INBOX_GROUP_BY_KEY = "paperclip:inbox:group-by";
export const INBOX_FILTER_PREFERENCES_KEY_PREFIX = "paperclip:inbox:filters";
export const INBOX_COLLAPSED_GROUPS_KEY_PREFIX = "paperclip:inbox:collapsed-groups";
export const INBOX_COLLAPSED_PARENTS_KEY_PREFIX = "paperclip:inbox:collapsed-parents";
export type InboxTab = "mine" | "recent" | "unread" | "blocked" | "all";
export type InboxCategoryFilter =
| "everything"
Expand Down Expand Up @@ -187,6 +188,11 @@ function getInboxCollapsedGroupsStorageKey(companyId: string | null | undefined)
return `${INBOX_COLLAPSED_GROUPS_KEY_PREFIX}:${companyId}`;
}

function getInboxCollapsedParentsStorageKey(companyId: string | null | undefined): string | null {
if (!companyId) return null;
return `${INBOX_COLLAPSED_PARENTS_KEY_PREFIX}:${companyId}`;
}

export function loadInboxFilterPreferences(
companyId: string | null | undefined,
): InboxFilterPreferences {
Expand Down Expand Up @@ -271,6 +277,36 @@ export function saveCollapsedInboxGroupKeys(
}
}

export function loadCollapsedInboxParentIds(
companyId: string | null | undefined,
): Set<string> {
const storageKey = getInboxCollapsedParentsStorageKey(companyId);
if (!storageKey) return new Set();

try {
const raw = localStorage.getItem(storageKey);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
return new Set(Array.isArray(parsed) ? parsed.filter((entry): entry is string => typeof entry === "string") : []);
} catch {
return new Set();
}
}

export function saveCollapsedInboxParentIds(
companyId: string | null | undefined,
parentIds: ReadonlySet<string>,
) {
const storageKey = getInboxCollapsedParentsStorageKey(companyId);
if (!storageKey) return;

try {
localStorage.setItem(storageKey, JSON.stringify([...parentIds]));
} catch {
// Ignore localStorage failures.
}
}

export function loadDismissedInboxAlerts(): Set<string> {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
Expand Down
74 changes: 74 additions & 0 deletions ui/src/pages/Inbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,80 @@ describe("Inbox toolbar", () => {
act(() => root.unmount());
});

it("restores folded and unfolded sub-tasks across remounts", async () => {
routerMock.location.pathname = "/inbox/mine";
const storageKey = "paperclip:inbox:collapsed-parents:company-1";
localStorage.removeItem(storageKey);

const parent = createIssue({
id: "parent-issue",
identifier: "PAP-1001",
title: "Parent inbox task",
});
const child = createIssue({
id: "child-issue",
identifier: "PAP-1002",
parentId: parent.id,
title: "Nested inbox task",
});
apiMocks.issuesList.mockResolvedValue([parent, child]);

const mountInbox = async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
await vi.waitFor(() => {
expect(container.textContent).toContain(parent.title);
});
return root;
};
const parentToggle = () => {
const parentRow = Array.from(container.querySelectorAll("[data-inbox-item]"))
.find((row) => row.textContent?.includes(parent.title));
return parentRow?.querySelector<HTMLButtonElement>('button[data-slot="icon-button"]') ?? null;
};

let root = await mountInbox();
try {
expect(container.textContent).toContain(child.title);

await act(async () => {
parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await vi.waitFor(() => {
expect(container.textContent).not.toContain(child.title);
});
expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([parent.id]);

act(() => root.unmount());
root = await mountInbox();
expect(container.textContent).not.toContain(child.title);

await act(async () => {
parentToggle()?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await vi.waitFor(() => {
expect(container.textContent).toContain(child.title);
});
expect(JSON.parse(localStorage.getItem(storageKey) ?? "[]")).toEqual([]);

act(() => root.unmount());
root = await mountInbox();
expect(container.textContent).toContain(child.title);
} finally {
localStorage.removeItem(storageKey);
act(() => root.unmount());
}
});

it("shows blocked toolbar controls on the Blocked tab", async () => {
routerMock.location.pathname = "/inbox/blocked";
const queryClient = new QueryClient({
Expand Down
13 changes: 10 additions & 3 deletions ui/src/pages/Inbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ import {
isInboxEntityDismissed,
isMineInboxTab,
loadCollapsedInboxGroupKeys,
loadCollapsedInboxParentIds,
loadInboxFilterPreferences,
loadInboxIssueColumns,
loadInboxNesting,
Expand All @@ -155,6 +156,7 @@ import {
resolveInboxSelectionIndex,
saveInboxFilterPreferences,
saveCollapsedInboxGroupKeys,
saveCollapsedInboxParentIds,
saveInboxIssueColumns,
saveInboxNesting,
saveInboxWorkItemGroupBy,
Expand Down Expand Up @@ -801,6 +803,7 @@ export function Inbox() {
previousSelectedCompanyIdRef.current = selectedCompanyId;
setFilterPreferences(loadInboxFilterPreferences(selectedCompanyId));
setCollapsedGroupKeys(loadCollapsedInboxGroupKeys(selectedCompanyId));
setCollapsedInboxParents(loadCollapsedInboxParentIds(selectedCompanyId));
}
}, [selectedCompanyId]);

Expand Down Expand Up @@ -1372,7 +1375,9 @@ export function Inbox() {
return next;
});
}, []);
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(new Set());
const [collapsedInboxParents, setCollapsedInboxParents] = useState<Set<string>>(
() => loadCollapsedInboxParentIds(selectedCompanyId),
);
const [collapsedGroupKeys, setCollapsedGroupKeys] = useState<Set<string>>(() => loadCollapsedInboxGroupKeys(selectedCompanyId));
const toggleGroupCollapse = useCallback((groupKey: string) => {
setCollapsedGroupKeys((prev) => {
Expand Down Expand Up @@ -1508,18 +1513,20 @@ export function Inbox() {
const next = new Set(prev);
if (next.has(parentId)) next.delete(parentId);
else next.add(parentId);
saveCollapsedInboxParentIds(selectedCompanyId, next);
return next;
});
}, []);
}, [selectedCompanyId]);
const setInboxParentCollapsed = useCallback((parentId: string, collapsed: boolean) => {
setCollapsedInboxParents((prev) => {
if (prev.has(parentId) === collapsed) return prev;
const next = new Set(prev);
if (collapsed) next.add(parentId);
else next.delete(parentId);
saveCollapsedInboxParentIds(selectedCompanyId, next);
return next;
});
}, []);
}, [selectedCompanyId]);

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