Merge pull request #898 from warmbly/devin/1791542893-consistent-mailbox-tag-order

feat: preserve mailbox ordering when filtering Accounts by a tag
This commit is contained in:
Matthew Meszaros
2026-10-09 11:28:49 +00:00
committed by GitHub
3 changed files with 128 additions and 3 deletions
+1 -1
View File
@@ -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
+125
View File
@@ -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<typeof UserModule>(),
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(
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
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");
});
});
+2 -2
View File
@@ -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 <NoAccess feature="email accounts" permissionLabel="Manage mailboxes" />;