Skip to content
Merged
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
36 changes: 20 additions & 16 deletions packages/junior-dashboard/e2e/review-desk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) },
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 ({
Expand Down
70 changes: 26 additions & 44 deletions packages/junior-dashboard/src/client/review-desk/ReviewDeskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<div
className={cn(
Expand All @@ -38,11 +34,6 @@ function InboxEmpty(props: {
<p className="m-0 font-display text-lg font-medium text-dashboard-text">
Nothing needs you
</p>
{props.onShowArchived ? (
<Button onClick={props.onShowArchived} variant="quiet">
See archived items
</Button>
) : null}
</div>
);
}
Expand Down Expand Up @@ -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<ReadonlySet<InboxGroupKey>>(
() => new Set(["archived"]),
() => new Set(),
);
const toggle = (key: InboxGroupKey) =>
setCollapsed((current) => {
Expand All @@ -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
Expand All @@ -125,20 +113,19 @@ 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
.filter((item) => !item.automationId)
.map((item) => item.addedByConversationId),
),
];
}, [groups, selected, shownCollapsed]);
}, [collapsed, groups, results, selected]);
const sourceTitles = useLinkedConversationTitles(sourceIds);
const sourceTitle = (item: ReviewItemPayload) =>
sourceTitles[sourceIds.indexOf(item.addedByConversationId)];
Expand All @@ -162,9 +149,8 @@ export function ReviewDeskPage() {
)}
>
<ReviewDeskSidebar
archived={groups.archived}
blocked={blockedAutomations}
collapsed={shownCollapsed}
collapsed={collapsed}
error={Boolean(items.error && !items.data)}
footer={
<>
Expand All @@ -179,29 +165,25 @@ export function ReviewDeskPage() {
Could not load more items. Try again.
</InlineError>
) : null}
{moreArchived && !shownCollapsed.has("archived") ? (
{moreArchived ? (
<p className="m-0 px-3 py-2 text-xs text-dashboard-text-muted">
Search to find older archived items.
Narrow the search to find older archived items.
</p>
) : null}
{loaded && q && list.length === 0 ? (
<p className="m-0 px-3 py-8 text-center text-sm text-dashboard-text-muted">
No items match "{q}".
</p>
) : null}
{empty ? (
<InboxEmpty
className="md:hidden"
onShowArchived={showArchived}
/>
) : null}
{empty ? <InboxEmpty className="md:hidden" /> : null}
</>
}
fyi={groups.fyi}
loading={!items.data && !items.error}
needsYou={groups.needsYou}
onSearchChange={setSearch}
onToggle={toggle}
results={results}
search={search}
searchParams={listParams}
selectedId={selected?.id}
Expand Down Expand Up @@ -250,7 +232,7 @@ export function ReviewDeskPage() {
) : direct.error ? (
<InlineError className="p-7">Review item not found.</InlineError>
) : empty ? (
<InboxEmpty onShowArchived={showArchived} />
<InboxEmpty />
) : null}
</section>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<InboxGroupKey>;
/** Shown under the groups, such as an empty state or load more. */
Expand All @@ -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;
Expand All @@ -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 (
<InboxRow
dot={dot}
dot={open ? (item.kind === "action" ? "accent" : "muted") : "closed"}
key={item.id}
meta={source ? `${source} · ${age}` : age}
meta={meta.filter(Boolean).join(" · ")}
selected={
item.id !== props.selectedId
? undefined
Expand Down Expand Up @@ -202,6 +207,18 @@ export function ReviewDeskSidebar(props: {
</InlineError>
) : props.loading ? (
<InboxRowsLoading />
) : props.results ? (
<>
{props.results.length > 0 ? (
<ul
aria-label="Search results"
className="m-0 grid list-none gap-0.5 p-0 pt-1"
>
{props.results.map(itemRow)}
</ul>
) : null}
{props.footer}
</>
) : (
<>
{groups.map((group, index) => (
Expand All @@ -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)}
</InboxGroup>
))}
<InboxGroup
Expand All @@ -235,15 +250,6 @@ export function ReviewDeskSidebar(props: {
/>
))}
</InboxGroup>
<InboxGroup
collapsed={props.collapsed.has("archived")}
count={props.archived.length}
first={firstKey(3)}
label="Archived"
onToggle={() => props.onToggle("archived")}
>
{props.archived.map((item) => itemRow(item, "closed"))}
</InboxGroup>
{props.footer}
</>
)}
Expand Down
6 changes: 2 additions & 4 deletions packages/junior-dashboard/src/client/review-desk/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
Expand All @@ -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),
};
Expand Down
Loading