From 2e18e1fa8b6432b51524f2eb4de1f6b98dfb3252 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:02:05 +0000 Subject: [PATCH] feat(dashboard): Show archived inbox items only in search Drop the Archived group from the inbox. A search now lists open and archived matches in one list, with archived rows marked by a hollow dot, a muted title, and their Done or Dismissed status. Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- .../junior-dashboard/e2e/review-desk.spec.ts | 36 +++++----- .../src/client/review-desk/ReviewDeskPage.tsx | 70 +++++++------------ .../client/review-desk/ReviewDeskSidebar.tsx | 52 ++++++++------ .../src/client/review-desk/queries.ts | 6 +- .../client/review-desk/reviewItemFormat.ts | 8 +-- 5 files changed, 80 insertions(+), 92 deletions(-) diff --git a/packages/junior-dashboard/e2e/review-desk.spec.ts b/packages/junior-dashboard/e2e/review-desk.spec.ts index 12a98e2603..5b7f052771 100644 --- a/packages/junior-dashboard/e2e/review-desk.spec.ts +++ b/packages/junior-dashboard/e2e/review-desk.spec.ts @@ -128,9 +128,8 @@ async function mockReviewItems(page: Page, options: { open?: unknown[] } = {}) { if (path === "/count") return route.fulfill({ json: { needsViewer: 3 } }); if (path === "" || path === "/") { const q = url.searchParams.get("q")?.toLowerCase() ?? ""; - // A search or the inbox also finds archived items, after the open ones. - const withArchived = q || url.searchParams.get("include") === "archived"; - const list = withArchived ? [...open, ...archived] : open; + // A search also finds archived items, after the open ones. + const list = q ? [...open, ...archived] : open; return route.fulfill({ json: { items: list.filter((i) => i.title.toLowerCase().includes(q)) }, }); @@ -233,7 +232,8 @@ test("the inbox shows an item in its conversation", async ({ await expect( list.getByRole("link", { name: new RegExp(license.title) }), ).toHaveAttribute("aria-current", "page"); - // Archived items stay collapsed until the reader asks. + // Archived items show only in a search. + await expect(page.getByRole("region", { name: "Archived" })).toHaveCount(0); await expect( list.getByRole("link", { name: new RegExp(answered.title) }), ).toHaveCount(0); @@ -289,35 +289,39 @@ test("on mobile the list opens one item at a time", async ({ await expect(list).toBeVisible(); }); -test("a search finds open and archived items", async ({ dashboard, page }) => { +test("a search lists open and archived items together", async ({ + dashboard, + page, +}) => { await mockReviewItems(page); await page.goto(`${dashboard.baseURL}/review`); const detail = page.getByRole("region", { name: "Selected review item" }); await page.getByLabel("Search review items").fill("license"); await expect(page).toHaveURL(/[?&]q=license/); - const archivedGroup = page.getByRole("region", { name: "Archived" }); - await archivedGroup - .getByRole("link", { name: new RegExp(answered.title) }) - .click(); + const results = page.getByRole("list", { name: "Search results" }); + await expect(results.getByRole("link")).toHaveCount(3); + await expect(page.getByRole("region", { name: "Needs you" })).toHaveCount(0); + await expect( + results.getByRole("link", { name: new RegExp(answered.title) }), + ).toContainText("Done"); + await screenshot(page, "review-desk-search", { view: "desktop" }); + await results.getByRole("link", { name: new RegExp(answered.title) }).click(); await expect( detail.getByRole("heading", { name: answered.title }), ).toBeVisible(); await expect(detail.getByText(/closed this/)).toBeVisible(); }); -test("an empty inbox links to archived items", async ({ dashboard, page }) => { +test("an empty inbox hides archived items", async ({ dashboard, page }) => { await mockReviewItems(page, { open: [] }); await mockNoBlockedAutomations(page); await page.goto(`${dashboard.baseURL}/review`); const detail = page.getByRole("region", { name: "Selected review item" }); await expect(detail.getByText("Nothing needs you")).toBeVisible(); - await screenshot(page, "review-desk-empty", { view: "desktop" }); - await detail.getByRole("button", { name: "See archived items" }).click(); await expect( - page - .getByRole("region", { name: "Archived" }) - .getByRole("link", { name: new RegExp(answered.title) }), - ).toBeVisible(); + page.getByRole("link", { name: new RegExp(answered.title) }), + ).toHaveCount(0); + await screenshot(page, "review-desk-empty", { view: "desktop" }); }); test("a message asks before it drops unsent picks", async ({ diff --git a/packages/junior-dashboard/src/client/review-desk/ReviewDeskPage.tsx b/packages/junior-dashboard/src/client/review-desk/ReviewDeskPage.tsx index 2f7206fe8e..e7393581f4 100644 --- a/packages/junior-dashboard/src/client/review-desk/ReviewDeskPage.tsx +++ b/packages/junior-dashboard/src/client/review-desk/ReviewDeskPage.tsx @@ -5,7 +5,6 @@ import type { ReviewItemPayload } from "@sentry/junior/api/schema"; import { InlineError } from "../components/InlineError"; import { LoadMorePagination } from "../components/Pagination"; import { useDetailsPanel } from "../components/DetailsPanel"; -import { Button } from "../components/ui/button"; import { Skeleton } from "../components/ui/skeleton"; import { useDocumentTitle } from "../documentTitle"; import { pathWithSearch, useDebouncedSearchParam } from "../searchParams"; @@ -24,10 +23,7 @@ import { inboxGroups, nextInboxItem, reviewItemPath } from "./reviewItemFormat"; const shellClass = "grid h-full min-h-0 w-full overflow-hidden bg-dashboard-bg md:grid-cols-[20rem_minmax(0,1fr)]"; -function InboxEmpty(props: { - className?: string; - onShowArchived?: () => void; -}) { +function InboxEmpty(props: { className?: string }) { return (
Nothing needs you

- {props.onShowArchived ? ( - - ) : null}
); } @@ -78,22 +69,29 @@ export function ReviewDeskPage() { useDocumentTitle("Inbox"); const [searchParams] = useSearchParams(); const [search, setSearch, q] = useDebouncedSearchParam(); - const { - items: list, - moreArchived, - query: items, - } = useReviewItemsData({ includeArchived: true, q }); + const { items: list, moreArchived, query: items } = useReviewItemsData({ q }); const showBlocked = !q; const blocked = useBlockedAutomationsData(showBlocked); const blockedAutomations = showBlocked ? (blocked.data?.automations ?? []) : []; const groups = useMemo(() => inboxGroups(list), [list]); + // A search lists every match in one list. Later pages add open items, so + // keep open matches before archived ones. + const results = useMemo( + () => + q + ? [ + ...list.filter((item) => item.status === "open"), + ...list.filter((item) => item.status !== "open"), + ] + : undefined, + [list, q], + ); const open = [...groups.needsYou, ...groups.fyi]; - // Archived items stay out of the way until a search or the reader asks. const [collapsed, setCollapsed] = useState>( - () => new Set(["archived"]), + () => new Set(), ); const toggle = (key: InboxGroupKey) => setCollapsed((current) => { @@ -102,16 +100,6 @@ export function ReviewDeskPage() { else next.add(key); return next; }); - const shownCollapsed = useMemo(() => { - if (!q || !collapsed.has("archived")) return collapsed; - const next = new Set(collapsed); - next.delete("archived"); - return next; - }, [collapsed, q]); - const showArchived = - groups.archived.length > 0 && shownCollapsed.has("archived") - ? () => toggle("archived") - : undefined; const itemParam = searchParams.get("item") ?? undefined; // Wide screens select the item that waited longest. Small screens show @@ -125,12 +113,11 @@ export function ReviewDeskPage() { // Read source titles only for the rows on screen and the selected item. const sourceIds = useMemo(() => { - const shown = [ - ...(shownCollapsed.has("needs-you") ? [] : groups.needsYou), - ...(shownCollapsed.has("fyi") ? [] : groups.fyi), - ...(shownCollapsed.has("archived") ? [] : groups.archived), - ...(selected ? [selected] : []), + const rows = results ?? [ + ...(collapsed.has("needs-you") ? [] : groups.needsYou), + ...(collapsed.has("fyi") ? [] : groups.fyi), ]; + const shown = selected ? [...rows, selected] : rows; return [ ...new Set( shown @@ -138,7 +125,7 @@ export function ReviewDeskPage() { .map((item) => item.addedByConversationId), ), ]; - }, [groups, selected, shownCollapsed]); + }, [collapsed, groups, results, selected]); const sourceTitles = useLinkedConversationTitles(sourceIds); const sourceTitle = (item: ReviewItemPayload) => sourceTitles[sourceIds.indexOf(item.addedByConversationId)]; @@ -162,9 +149,8 @@ export function ReviewDeskPage() { )} > @@ -179,9 +165,9 @@ export function ReviewDeskPage() { Could not load more items. Try again. ) : null} - {moreArchived && !shownCollapsed.has("archived") ? ( + {moreArchived ? (

- Search to find older archived items. + Narrow the search to find older archived items.

) : null} {loaded && q && list.length === 0 ? ( @@ -189,12 +175,7 @@ export function ReviewDeskPage() { No items match "{q}".

) : null} - {empty ? ( - - ) : null} + {empty ? : null} } fyi={groups.fyi} @@ -202,6 +183,7 @@ export function ReviewDeskPage() { needsYou={groups.needsYou} onSearchChange={setSearch} onToggle={toggle} + results={results} search={search} searchParams={listParams} selectedId={selected?.id} @@ -250,7 +232,7 @@ export function ReviewDeskPage() { ) : direct.error ? ( Review item not found. ) : empty ? ( - + ) : null} diff --git a/packages/junior-dashboard/src/client/review-desk/ReviewDeskSidebar.tsx b/packages/junior-dashboard/src/client/review-desk/ReviewDeskSidebar.tsx index 4e3bbad68d..488787fc3c 100644 --- a/packages/junior-dashboard/src/client/review-desk/ReviewDeskSidebar.tsx +++ b/packages/junior-dashboard/src/client/review-desk/ReviewDeskSidebar.tsx @@ -12,10 +12,10 @@ import { ConversationSectionHeading } from "../conversations/ConversationSection import { automationPath, formatRelativeTime } from "../format"; import { pathWithSearch } from "../searchParams"; import { cn } from "../styles"; -import { reviewItemPath } from "./reviewItemFormat"; +import { reviewItemPath, reviewItemStatusLabel } from "./reviewItemFormat"; /** A sidebar group key. The page owns which groups are collapsed. */ -export type InboxGroupKey = "archived" | "blocked" | "fyi" | "needs-you"; +export type InboxGroupKey = "blocked" | "fyi" | "needs-you"; type Dot = "accent" | "muted" | "warning" | "closed"; @@ -120,11 +120,11 @@ function InboxRowsLoading() { } /** - * List the inbox: open items that need the viewer, notices, blocked - * Automations, and archived items, each in a group that collapses. + * List the inbox: open items that need the viewer, notices, and blocked + * Automations, each in a group that collapses. A search shows one list of + * open and archived matches instead. */ export function ReviewDeskSidebar(props: { - archived: ReviewItemPayload[]; blocked: AutomationSummary[]; collapsed: ReadonlySet; /** Shown under the groups, such as an empty state or load more. */ @@ -135,6 +135,8 @@ export function ReviewDeskSidebar(props: { needsYou: ReviewItemPayload[]; onSearchChange(value: string): void; onToggle(key: InboxGroupKey): void; + /** Search matches in API order. Set while a search is active. */ + results?: ReviewItemPayload[]; search: string; searchParams: URLSearchParams; selectedId?: string; @@ -153,16 +155,19 @@ export function ReviewDeskSidebar(props: { ]; const firstKey = (index: number) => shown.slice(0, index).every((count) => count === 0); - const itemRow = (item: ReviewItemPayload, dot: Dot) => { + const itemRow = (item: ReviewItemPayload) => { + const open = item.status === "open"; const source = item.automationId ? "Automation" : props.sourceTitle(item); - const age = formatRelativeTime( - item.status === "open" ? item.createdAt : item.closedAt, - ); + const meta = [ + open ? undefined : reviewItemStatusLabel(item), + source, + formatRelativeTime(open ? item.createdAt : item.closedAt), + ]; return ( ) : props.loading ? ( + ) : props.results ? ( + <> + {props.results.length > 0 ? ( +
    + {props.results.map(itemRow)} +
+ ) : null} + {props.footer} + ) : ( <> {groups.map((group, index) => ( @@ -213,9 +230,7 @@ export function ReviewDeskSidebar(props: { label={group.label} onToggle={() => props.onToggle(group.key)} > - {group.items.map((item) => - itemRow(item, item.kind === "action" ? "accent" : "muted"), - )} + {group.items.map(itemRow)} ))} ))} - props.onToggle("archived")} - > - {props.archived.map((item) => itemRow(item, "closed"))} - {props.footer} )} diff --git a/packages/junior-dashboard/src/client/review-desk/queries.ts b/packages/junior-dashboard/src/client/review-desk/queries.ts index 315d43a8e9..2e95bafa0f 100644 --- a/packages/junior-dashboard/src/client/review-desk/queries.ts +++ b/packages/junior-dashboard/src/client/review-desk/queries.ts @@ -25,19 +25,17 @@ const COUNT_REFRESH_MS = 60_000; /** * List the open Review items that the viewer can see, one page at a time. - * A search or `includeArchived` also finds archived items, with the first - * page. `items` holds the items of every loaded page, and `query` loads more. + * A search also finds archived items, with the first page. `items` holds the + * items of every loaded page, and `query` loads more. */ export function useReviewItemsData(filters: { conversationId?: string; - includeArchived?: boolean; q?: string; }) { const params = new URLSearchParams(); if (filters.conversationId) { params.set("conversationId", filters.conversationId); } - if (filters.includeArchived) params.set("include", "archived"); if (filters.q) params.set("q", filters.q); const search = params.toString(); const query = useInfiniteQuery({ diff --git a/packages/junior-dashboard/src/client/review-desk/reviewItemFormat.ts b/packages/junior-dashboard/src/client/review-desk/reviewItemFormat.ts index 6b03fe6f00..e6cb063e83 100644 --- a/packages/junior-dashboard/src/client/review-desk/reviewItemFormat.ts +++ b/packages/junior-dashboard/src/client/review-desk/reviewItemFormat.ts @@ -101,9 +101,8 @@ export function reviewItemWaitsForAnswer(item: ReviewItemPayload): boolean { ); } -/** The Review items of the inbox, in the groups and order the list shows. */ +/** The open Review items of the inbox, in the groups and order it shows. */ export type InboxGroups = { - archived: ReviewItemPayload[]; fyi: ReviewItemPayload[]; needsYou: ReviewItemPayload[]; }; @@ -117,13 +116,12 @@ function inboxOrder(a: ReviewItemPayload, b: ReviewItemPayload): number { } /** - * Split items into inbox groups. Open items show oldest first, so the item - * that waited longest is at the top. Archived items keep the API order. + * Split open items into inbox groups, oldest first, so the item that waited + * longest is at the top. Archived items are in no group. */ export function inboxGroups(items: readonly ReviewItemPayload[]): InboxGroups { const open = items.filter((item) => item.status === "open"); return { - archived: items.filter((item) => item.status !== "open"), fyi: open.filter((item) => item.kind === "notice").sort(inboxOrder), needsYou: open.filter((item) => item.kind === "action").sort(inboxOrder), };