From f6c1c645202d08233b51f89883245b758deff29e Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 9 Oct 2026 10:54:39 +0000 Subject: [PATCH] feat: preserve complete tag-set grouping and explicit mailbox ordering when filtering Accounts by a tag Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/content/docs/guides/mailboxes.mdx | 2 +- web/src/app/app/emails/page.test.tsx | 125 +++++++++++++++++++++++++ web/src/app/app/emails/page.tsx | 4 +- 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 web/src/app/app/emails/page.test.tsx diff --git a/docs/content/docs/guides/mailboxes.mdx b/docs/content/docs/guides/mailboxes.mdx index e8d2e252c..80b562d78 100644 --- a/docs/content/docs/guides/mailboxes.mdx +++ b/docs/content/docs/guides/mailboxes.mdx @@ -15,7 +15,7 @@ New mailboxes default to diagnostic participation **off**. The organization-auth Use the **Tags** dropdown above the mailbox list to show accounts with a particular tag. The selection is kept in the page URL and remembered for your user and workspace in the browser tab, so refreshing, opening a bookmark, or visiting another page and returning keeps the filter. Choose **All accounts** to clear it. A tag that no longer exists in the current workspace falls back to **All accounts**. -Without an explicit column sort, **All accounts** groups the loaded mailboxes by their complete set of tags. Mailboxes with **Tag A + Tag B** stay together even when those tags were assigned in a different order, separately from mailboxes with only **Tag A**. Groups follow the workspace's tag order, with untagged mailboxes last; mailboxes within a group are ordered by email address. Each mailbox appears only once. Selecting a column sort takes priority over grouping. +Without an explicit column sort, the Accounts list groups the loaded mailboxes by their complete set of tags, both in **All accounts** and when filtering by a specific tag. Mailboxes with **Tag A + Tag B** stay together even when those tags were assigned in a different order, separately from mailboxes with only **Tag A**. Groups follow the workspace's tag order, with untagged mailboxes last; mailboxes within a group are ordered by email address. Selecting a tag narrows the list without changing this order. Each mailbox appears only once. Selecting a column sort takes priority over grouping and remains selected when changing the tag filter. ## Connecting an account diff --git a/web/src/app/app/emails/page.test.tsx b/web/src/app/app/emails/page.test.tsx new file mode 100644 index 000000000..77f4dff32 --- /dev/null +++ b/web/src/app/app/emails/page.test.tsx @@ -0,0 +1,125 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createMemoryHistory, createRootRoute, createRouter, RouterProvider } from "@tanstack/react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type Inbox from "@/lib/api/models/app/emails/Inbox"; +import type Tag from "@/lib/api/models/app/Tag"; +import type * as UserModule from "@/hooks/context/user"; +import AddressesPage from "./page"; + +const { tags, rows } = vi.hoisted(() => ({ + tags: [ + { id: "sending", title: "Sending account", color: "#0088cc", position: 0 }, + { id: "serveblink", title: "Serveblink.com", color: "#008800", position: 1 }, + { id: "virevow", title: "Virevow.com", color: "#8800cc", position: 2 }, + ] as Tag[], + rows: [ + { id: "sara", email: "sara@virevow.example", tags: ["virevow", "sending"] }, + { id: "kavya", email: "kavya@serveblink.example", tags: ["serveblink", "sending"] }, + { id: "ananya", email: "ananya@serveblink.example", tags: ["sending", "serveblink"] }, + { id: "julie", email: "julie@serveblink.example", tags: ["serveblink", "sending"] }, + ].map((row) => ({ ...row, name: row.id, status: "active", provider: "gmail", campaign_limit: 50 })) as Inbox[], +})); + +vi.mock("@/hooks/context/user", async (original) => ({ + ...await original(), + useUserProfile: () => ({ user: { id: "tag-order-user", tags } }), +})); +vi.mock("@/hooks/context/confirm", () => ({ useConfirm: () => ({ show: vi.fn() }) })); +vi.mock("@/hooks/usePermission", () => ({ usePermission: () => true })); +vi.mock("@/lib/api/hooks/app/emails/useEmails", () => ({ + default: ({ tag }: { tag: string }) => ({ emails: tag ? rows.filter((row) => row.tags.includes(tag)) : rows }), +})); +vi.mock("@/lib/api/hooks/app/analytics/useAccountStatuses", () => ({ default: () => ({ data: [] }) })); +vi.mock("@/lib/api/hooks/app/subscription/useFeatureStatus", () => ({ default: () => ({ data: { can_use_warmup: true } }) })); +vi.mock("@/lib/api/hooks/auth/useAuthConfig", () => ({ default: () => ({ data: {}, isLoading: false }) })); +vi.mock("@/lib/api/hooks/app/emails/useMailboxGrants", () => ({ useSigninMigration: () => ({ data: { data: [] } }) })); +vi.mock("@/lib/api/hooks/app/advisor/useAdvisor", () => ({ useAdvisorEntityIndex: () => ({ get: () => [] }) })); +vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({ selfHosted: false, connected: false }) })); +vi.mock("@/lib/api/hooks/app/cloudlink/useCloudLink", () => ({ + useEnrollCloudLinkMailbox: () => ({}), useUnenrollCloudLinkMailbox: () => ({}), useCloudLinkMailboxLifecycle: () => ({}), +})); +vi.mock("@/lib/api/hooks/app/emails/useWarmupLifecycle", () => ({ default: () => ({}) })); +vi.mock("@/lib/api/hooks/app/emails/useRemoveEmail", () => ({ default: () => ({}) })); +vi.mock("@/components/app/emails/useMailboxSwitch", () => ({ default: () => ({}), switchOffPrompt: vi.fn() })); +vi.mock("@/components/app/emails/InboxDetails", () => ({ default: () => null })); +vi.mock("@/components/app/emails/BulkWarmupDialog", () => ({ default: () => null })); +vi.mock("@/components/app/emails/migration/SigninMigrationDialog", () => ({ default: () => null })); +vi.mock("@/components/app/emails/import/grants/MailboxGrantDialog", () => ({ default: () => null })); +vi.mock("@/components/app/cloud/CloudConnectDialog", () => ({ default: () => null })); +vi.mock("@/components/app/emails/import/MailboxImportsMenu", () => ({ default: () => null })); +vi.mock("@/components/app/emails/CloudPathsPanel", () => ({ default: () => null })); +vi.mock("@/components/app/advisor/AdvisorRowFlag", () => ({ default: () => null })); +vi.mock("@/components/app/advisor/AdvisorSummaryBar", () => ({ default: () => null })); + +async function renderPage(tag = "") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const router = createRouter({ + routeTree: createRootRoute({ component: AddressesPage }), + history: createMemoryHistory({ initialEntries: [`/${tag ? `?tag=${tag}` : ""}`] }), + }); + await router.load(); + return render( + + + , + ); +} + +function orderedEmails() { + return screen.getAllByRole("row").slice(1).map((row) => within(row).getByText(/@/).textContent); +} + +describe("mailbox ordering with a tag filter", () => { + beforeEach(() => { + sessionStorage.clear(); + vi.spyOn(window, "scrollTo").mockImplementation(() => {}); + }); + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + }); + + it("keeps the existing All Accounts tag-set grouping and email order", async () => { + await renderPage(); + expect(orderedEmails()).toEqual(["ananya@serveblink.example", "julie@serveblink.example", "kavya@serveblink.example", "sara@virevow.example"]); + }); + + it("keeps that order when a specific tag returns rows in creation order", async () => { + await renderPage("serveblink"); + expect(orderedEmails()).toEqual(["ananya@serveblink.example", "julie@serveblink.example", "kavya@serveblink.example"]); + expect(rows.map((row) => row.id)).toEqual(["sara", "kavya", "ananya", "julie"]); + }); + + it("honors explicit ascending and descending column sorts with a tag selected", async () => { + await renderPage("serveblink"); + fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); + expect(orderedEmails()).toEqual(["ananya@serveblink.example", "julie@serveblink.example", "kavya@serveblink.example"]); + fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); + expect(orderedEmails()).toEqual(["kavya@serveblink.example", "julie@serveblink.example", "ananya@serveblink.example"]); + fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); + expect(orderedEmails()).toEqual(["ananya@serveblink.example", "julie@serveblink.example", "kavya@serveblink.example"]); + }); + + it("preserves grouped order through All Accounts, a tag, and back to All Accounts", async () => { + await renderPage(); + const all = orderedEmails(); + fireEvent.click(screen.getByRole("button", { name: "All accounts" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Serveblink.com" })); + await waitFor(() => expect(orderedEmails()).toEqual(all.filter((email) => email?.endsWith("@serveblink.example")))); + fireEvent.click(screen.getByRole("button", { name: "Serveblink.com" })); + fireEvent.click(screen.getByRole("menuitem", { name: "All accounts" })); + await waitFor(() => expect(orderedEmails()).toEqual(all)); + }); + + it("retains an explicit column sort when changing tags", async () => { + await renderPage(); + fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); + fireEvent.click(screen.getByRole("button", { name: "Mailbox" })); + const all = orderedEmails(); + fireEvent.click(screen.getByRole("button", { name: "All accounts" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Serveblink.com" })); + await waitFor(() => expect(orderedEmails()).toEqual(all.filter((email) => email?.endsWith("@serveblink.example")))); + expect(screen.getByRole("columnheader", { name: "Mailbox" })).toHaveAttribute("aria-sort", "descending"); + }); +}); diff --git a/web/src/app/app/emails/page.tsx b/web/src/app/app/emails/page.tsx index e0f20b7f8..93262e8d6 100644 --- a/web/src/app/app/emails/page.tsx +++ b/web/src/app/app/emails/page.tsx @@ -374,7 +374,7 @@ export default function AddressesPage() { const sortedEmails = useMemo(() => { const list = emailsData.emails ?? []; const col = sort && MAILBOX_COLUMNS.find((c) => c.id === sort.by); - if (!sort || !col?.sortValue) return tag ? list : groupMailboxes(list, p.user.tags); + if (!sort || !col?.sortValue) return groupMailboxes(list, p.user.tags); const value = col.sortValue; const dir = sort.reverse ? 1 : -1; return [...list].sort((a, b) => { @@ -383,7 +383,7 @@ export default function AddressesPage() { if (x === y) return 0; return (x > y ? 1 : -1) * dir; }); - }, [emailsData.emails, sort, statusById, tag, p.user.tags]); + }, [emailsData.emails, sort, statusById, p.user.tags]); if (!canView) { return ;