mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-18 16:03:00 +00:00
feat: persist workspace-scoped mailbox domain analytics deliverability placement and import browsing controls with strict schemas and resource navigation restoration regressions
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
|
||||
import * as schemas from "@/lib/browse-accounts-analytics";
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={profile}>{children}</UserContext.Provider>;
|
||||
}
|
||||
|
||||
const cases: { name: string; schema: z.ZodType; initial: unknown; value: unknown }[] = [
|
||||
{ name: "analytics.range", schema: schemas.analyticsRange, initial: "7d", value: "90d" },
|
||||
{ name: "analytics.hiddenMetrics", schema: schemas.analyticsHiddenMetrics, initial: [], value: ["sent", "replies"] },
|
||||
{ name: "deliverability.view", schema: schemas.deliverabilityView, initial: { range: "7d", hidden: [] }, value: { range: "30d", hidden: ["providers", "mailboxes"] } },
|
||||
{ name: "deliverability.hiddenMetrics", schema: schemas.deliverabilityHiddenMetrics, initial: ["sent"], value: ["opens", "replies"] },
|
||||
{ name: "emails.search", schema: schemas.browseString, initial: "", value: "alice" },
|
||||
{ name: "emails.sort", schema: schemas.mailboxSort, initial: null, value: { by: "health", reverse: true } },
|
||||
{ name: "emails.mailbox.a.tab", schema: schemas.mailboxTab, initial: "overview", value: "analytics" },
|
||||
{ name: "emails.domains.filter", schema: schemas.domainFilter, initial: "all", value: "vendor" },
|
||||
{ name: "emails.domain.a.test.tab", schema: schemas.domainTab, initial: "overview", value: "tracking" },
|
||||
{ name: "emails.import.a.results.filter", schema: schemas.importFilter, initial: { status: "", cause: "" }, value: { status: "failed", cause: "password" } },
|
||||
{ name: "emails.import.vendor.a.pick.filter", schema: schemas.importPickFilter, initial: "all", value: "moving" },
|
||||
{ name: "emails.domains.bulkSetup.list.limit", schema: schemas.domainListLimit, initial: 20, value: 70 },
|
||||
{ name: "emails.mailbox.a.placement.range", schema: schemas.placementRange, initial: "30d", value: "14d" },
|
||||
{ name: "emails.mailbox.a.placement.group", schema: schemas.placementGroupFilter, initial: "all", value: "yahoo" },
|
||||
{ name: "deliverability.placement.providerExpanded", schema: schemas.placementGroup.nullable(), initial: null, value: "google" },
|
||||
{ name: "deliverability.placement.showAll", schema: schemas.browseBoolean, initial: false, value: true },
|
||||
{ name: "placement.batch.a.tab", schema: schemas.batchTab, initial: "mailboxes", value: "recipients" },
|
||||
{ name: "placement.batch.a.senders.sort", schema: schemas.batchSenderSort, initial: "worst", value: "status" },
|
||||
{ name: "placement.batch.a.senders.status", schema: schemas.batchSenderStatus, initial: "", value: "cancelled" },
|
||||
];
|
||||
|
||||
describe("accounts and analytics browse schemas", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it.each(cases)("restores $name synchronously after change and remount", ({ name, schema, initial, value }) => {
|
||||
const page = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner });
|
||||
act(() => page.result.current[1](value));
|
||||
page.unmount();
|
||||
const refreshed = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner });
|
||||
expect(refreshed.result.current[0]).toEqual(value);
|
||||
act(() => refreshed.result.current[1](initial));
|
||||
refreshed.unmount();
|
||||
const cleared = renderHook(() => useBrowseState(name, initial, schema), { wrapper: Owner });
|
||||
expect(cleared.result.current[0]).toEqual(initial);
|
||||
});
|
||||
|
||||
it("isolates import history filters by resource without erasing a remembered cause", () => {
|
||||
const page = renderHook(({ id }) => useBrowseState(`emails.import.${id}.results.filter`, { status: "", cause: "" }, schemas.importFilter), {
|
||||
wrapper: Owner, initialProps: { id: "a" },
|
||||
});
|
||||
act(() => page.result.current[1]({ status: "failed", cause: "password" }));
|
||||
page.rerender({ id: "b" });
|
||||
expect(page.result.current[0]).toEqual({ status: "", cause: "" });
|
||||
act(() => page.result.current[1]({ status: "needs_signin", cause: "" }));
|
||||
page.rerender({ id: "a" });
|
||||
expect(page.result.current[0]).toEqual({ status: "failed", cause: "password" });
|
||||
});
|
||||
|
||||
it("lets initial navigation override a remembered resource tab and remembers explicit clearing", () => {
|
||||
const key = `${BROWSE_STORAGE_PREFIX}account-user:workspace:emails.mailbox.a.tab`;
|
||||
sessionStorage.setItem(key, JSON.stringify({ value: "settings" }));
|
||||
const page = renderHook(() => useBrowseState("emails.mailbox.a.tab", "overview", schemas.mailboxTab, { initialOverride: "analytics" }), { wrapper: Owner });
|
||||
expect(page.result.current[0]).toBe("analytics");
|
||||
act(() => page.result.current[1]("overview"));
|
||||
page.unmount();
|
||||
const refreshed = renderHook(() => useBrowseState("emails.mailbox.a.tab", "overview", schemas.mailboxTab), { wrapper: Owner });
|
||||
expect(refreshed.result.current[0]).toBe("overview");
|
||||
});
|
||||
|
||||
it("rejects stale enums, malformed structures, duplicate metrics and hidden-all metrics", () => {
|
||||
expect(schemas.mailboxSort.safeParse({ by: "health", reverse: false, obsolete: true }).success).toBe(false);
|
||||
expect(schemas.mailboxSort.safeParse({ by: "old", reverse: false }).success).toBe(false);
|
||||
expect(schemas.mailboxSort.safeParse({ by: "health", reverse: "yes" }).success).toBe(false);
|
||||
expect(schemas.mailboxTab.safeParse("old").success).toBe(false);
|
||||
expect(schemas.domainTab.safeParse("old").success).toBe(false);
|
||||
expect(schemas.batchSenderStatus.safeParse("needs_signin").success).toBe(false);
|
||||
expect(schemas.analyticsHiddenMetrics.safeParse(["sent", "sent"]).success).toBe(false);
|
||||
expect(schemas.analyticsHiddenMetrics.safeParse(["sent", "opens", "clicks", "replies"]).success).toBe(false);
|
||||
expect(schemas.deliverabilityHiddenMetrics.safeParse(["sent", "opens", "replies", "bounces", "complaints"]).success).toBe(false);
|
||||
expect(schemas.deliverabilityView.safeParse({ range: "30d", hidden: ["old"] }).success).toBe(false);
|
||||
expect(schemas.deliverabilityView.safeParse({ range: "30d", hidden: ["chart", "chart"] }).success).toBe(false);
|
||||
expect(schemas.importFilter.safeParse({ status: "connected", cause: "password" }).success).toBe(false);
|
||||
expect(schemas.importFilter.safeParse({ status: "failed", cause: "password", cursor: "a" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([0, -1, 1.5, Infinity, NaN, null, "20"])("rejects invalid display limits: %s", (value) => {
|
||||
expect(schemas.domainListLimit.safeParse(value).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { NoAccess } from "@/components/layout/NoAccess";
|
||||
import { usePermission } from "@/hooks/usePermission";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { analyticsRange, analyticsHiddenMetrics } from "@/lib/browse-accounts-analytics";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import {
|
||||
ActivityIcon,
|
||||
@@ -60,9 +62,9 @@ function num(v: number | undefined): string {
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const canView = usePermission("VIEW_ANALYTICS");
|
||||
const [range, setRange] = useState<Range>("7d");
|
||||
const [range, setRange] = useBrowseState<Range>("analytics.range", "7d", analyticsRange);
|
||||
// Legend toggles: every metric charts together; hidden ones drop out.
|
||||
const [hiddenMetrics, setHiddenMetrics] = useState<Metric[]>([]);
|
||||
const [hiddenMetrics, setHiddenMetrics] = useBrowseState<Metric[]>("analytics.hiddenMetrics", [], analyticsHiddenMetrics);
|
||||
const dash = useDashboard(range);
|
||||
const d = dash.data;
|
||||
const os = d?.overall_stats;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
|
||||
import DeliverabilityPage from "./page";
|
||||
|
||||
vi.mock("@/lib/api/hooks/app/analytics/useDeliverability", () => ({ default: () => ({ data: undefined, isPending: true, refetch: vi.fn() }) }));
|
||||
vi.mock("@/components/app/placement/WarmupPlacementSection", () => ({ default: () => null }));
|
||||
vi.mock("@/components/app/advisor/AdvisorSummaryBar", () => ({ default: () => null }));
|
||||
vi.mock("@tanstack/react-router", () => ({ Link: ({ children }: { children: ReactNode }) => <a>{children}</a> }));
|
||||
|
||||
function Page() {
|
||||
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={profile}><DeliverabilityPage /></UserContext.Provider>;
|
||||
}
|
||||
const key = `${BROWSE_STORAGE_PREFIX}account-user:workspace:deliverability.view`;
|
||||
|
||||
describe("deliverability legacy browse preferences", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("seeds compatible legacy preferences once, deduplicates sections, and never dual-writes them", () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ range: "30d", hidden: ["providers", "providers", "old"] }));
|
||||
const page = render(<Page />);
|
||||
expect(JSON.parse(sessionStorage.getItem(key)!).value).toEqual({ range: "30d", hidden: ["providers"] });
|
||||
fireEvent.click(screen.getByRole("button", { name: "90d" }));
|
||||
page.unmount();
|
||||
render(<Page />);
|
||||
expect(JSON.parse(sessionStorage.getItem(key)!).value).toEqual({ range: "90d", hidden: ["providers"] });
|
||||
expect(localStorage.setItem).not.toHaveBeenCalledWith("warmbly:deliverability-view", expect.any(String));
|
||||
});
|
||||
|
||||
it("retains a scoped preference in preference to a global legacy value", () => {
|
||||
vi.mocked(localStorage.getItem).mockReturnValue(JSON.stringify({ range: "30d", hidden: ["chart"] }));
|
||||
sessionStorage.setItem(key, JSON.stringify({ value: { range: "90d", hidden: ["totals"] } }));
|
||||
render(<Page />);
|
||||
expect(JSON.parse(sessionStorage.getItem(key)!).value).toEqual({ range: "90d", hidden: ["totals"] });
|
||||
expect(localStorage.setItem).not.toHaveBeenCalledWith("warmbly:deliverability-view", expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@
|
||||
// is keyed under ["analytics", ...] which useRealtimeEvents already invalidates.
|
||||
// The visible sections and the time window are user-customizable and persisted.
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { deliverabilityView, deliverabilityHiddenMetrics } from "@/lib/browse-accounts-analytics";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { AlertTriangleIcon, ArrowUpRightIcon, CheckIcon, RefreshCcwIcon, SlidersHorizontalIcon } from "lucide-react";
|
||||
import { EmptyBlock, Page, PageBody, PageTopbar, SectionBar, Stat, StatStrip } from "@/components/layout/Page";
|
||||
@@ -88,7 +90,7 @@ function num(v: number | undefined): string {
|
||||
return (v ?? 0).toLocaleString();
|
||||
}
|
||||
|
||||
// View preferences (window + hidden sections), persisted per browser.
|
||||
// Legacy browser preferences seed the scoped session preference once.
|
||||
const VIEW_KEY = "warmbly:deliverability-view";
|
||||
|
||||
interface ViewPrefs {
|
||||
@@ -103,7 +105,7 @@ function loadView(): ViewPrefs {
|
||||
const v = JSON.parse(raw) as Partial<ViewPrefs>;
|
||||
return {
|
||||
range: v.range === "30d" || v.range === "90d" ? v.range : "7d",
|
||||
hidden: Array.isArray(v.hidden) ? v.hidden.filter((k): k is SectionKey => SECTIONS.some((s) => s.key === k)) : [],
|
||||
hidden: Array.isArray(v.hidden) ? [...new Set(v.hidden.filter((k): k is SectionKey => SECTIONS.some((s) => s.key === k)))] : [],
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -121,10 +123,10 @@ function BandChip({ band }: { band: DeliverabilityBand }) {
|
||||
}
|
||||
|
||||
export default function DeliverabilityPage() {
|
||||
const [view, setView] = useState<ViewPrefs>(loadView);
|
||||
const [view, setView] = useBrowseState<ViewPrefs>("deliverability.view", loadView, deliverabilityView);
|
||||
// Legend toggles: every metric charts together; "sent" starts hidden so
|
||||
// it doesn't dwarf the failure signals this page exists to surface.
|
||||
const [hiddenMetrics, setHiddenMetrics] = useState<Metric[]>(["sent"]);
|
||||
const [hiddenMetrics, setHiddenMetrics] = useBrowseState<Metric[]>("deliverability.hiddenMetrics", ["sent"], deliverabilityHiddenMetrics);
|
||||
const toggleMetric = (k: Metric) =>
|
||||
setHiddenMetrics((cur) => {
|
||||
if (cur.includes(k)) return cur.filter((x) => x !== k);
|
||||
@@ -137,11 +139,6 @@ export default function DeliverabilityPage() {
|
||||
|
||||
const updateView = (next: ViewPrefs) => {
|
||||
setView(next);
|
||||
try {
|
||||
localStorage.setItem(VIEW_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// Persisting is best-effort; the in-memory view still applies.
|
||||
}
|
||||
};
|
||||
const show = (k: SectionKey) => !view.hidden.includes(k);
|
||||
const toggleSection = (k: SectionKey) =>
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useCallback, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
|
||||
import type { SendingDomain } from "@/lib/api/models/app/emails/SendingDomain";
|
||||
import type { DomainTab } from "@/components/app/emails/domains/SendingDomainDrawer";
|
||||
import DomainsPage from "./page";
|
||||
|
||||
const navigation = vi.hoisted(() => ({ initial: "", consumed: "" }));
|
||||
vi.mock("@/hooks/useSearchParams", () => ({
|
||||
useSearchParams: () => {
|
||||
const [params, setParams] = useState(() => new URLSearchParams(navigation.initial));
|
||||
const set = useCallback((next: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams)) => {
|
||||
setParams((prev) => {
|
||||
const result = typeof next === "function" ? next(prev) : next;
|
||||
navigation.consumed = result.toString();
|
||||
return result;
|
||||
});
|
||||
}, []);
|
||||
return [params, set];
|
||||
},
|
||||
}));
|
||||
const domains: SendingDomain[] = ["a.test", "b.test"].map((domain) => ({
|
||||
domain, mailboxes: 1, mail_hosts: [], auth_state: "passing", auth_spf: true, auth_dkim: true, auth_dmarc: true, tracking_domains: [],
|
||||
}));
|
||||
vi.mock("@/lib/api/hooks/app/emails/useSendingDomains", () => ({
|
||||
useSendingDomains: () => ({ data: { data: domains }, isLoading: false }),
|
||||
}));
|
||||
vi.mock("@tanstack/react-router", () => ({ Link: ({ children }: { children: ReactNode }) => <a>{children}</a> }));
|
||||
vi.mock("@/components/app/emails/domains/RedirectRescue", () => ({ default: () => null }));
|
||||
vi.mock("@/components/app/emails/domains/BulkSetupDialog", () => ({ default: () => null }));
|
||||
vi.mock("@/components/app/emails/domains/DomainSelectionBar", () => ({ default: () => null }));
|
||||
vi.mock("@/components/app/emails/domains/SendingDomainDrawer", () => ({
|
||||
default: ({ open, tab, setTab, onClose }: { open: string; tab: DomainTab; setTab: (tab: DomainTab) => void; onClose: () => void }) => open ? (
|
||||
<section aria-label="Domain drawer">
|
||||
<output aria-label="Domain tab">{tab}</output>
|
||||
<button onClick={() => setTab("tracking")}>Choose tracking</button>
|
||||
<button onClick={() => setTab("overview")}>Choose overview</button>
|
||||
<button onClick={onClose}>Close domain</button>
|
||||
</section>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
function Page() {
|
||||
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={profile}><DomainsPage /></UserContext.Provider>;
|
||||
}
|
||||
|
||||
describe("domain drawer browse persistence", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
navigation.initial = "";
|
||||
navigation.consumed = "";
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("remembers per-domain tabs without reopening drawers on page refresh", () => {
|
||||
const page = render(<Page />);
|
||||
fireEvent.click(screen.getByText("a.test"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Choose tracking" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close domain" }));
|
||||
fireEvent.click(screen.getByText("b.test"));
|
||||
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("overview");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close domain" }));
|
||||
page.unmount();
|
||||
render(<Page />);
|
||||
expect(screen.queryByRole("region", { name: "Domain drawer" })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText("a.test"));
|
||||
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("tracking");
|
||||
});
|
||||
|
||||
it("prioritizes and consumes a URL tab intent, then remembers the user's reset", () => {
|
||||
const key = `${BROWSE_STORAGE_PREFIX}account-user:workspace:emails.domain.a.test.tab`;
|
||||
sessionStorage.setItem(key, JSON.stringify({ value: "tracking" }));
|
||||
navigation.initial = "domain=a.test&tab=redirect&keep=1";
|
||||
const page = render(<Page />);
|
||||
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("redirect");
|
||||
expect(navigation.consumed).toBe("keep=1");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Choose overview" }));
|
||||
page.unmount();
|
||||
navigation.initial = "";
|
||||
render(<Page />);
|
||||
fireEvent.click(screen.getByText("a.test"));
|
||||
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("overview");
|
||||
});
|
||||
|
||||
it("does not replay a consumed URL tab intent on workspace switching", () => {
|
||||
sessionStorage.setItem(`${BROWSE_STORAGE_PREFIX}account-user:other:emails.domain.a.test.tab`, JSON.stringify({ value: "tracking" }));
|
||||
navigation.initial = "domain=a.test&tab=redirect";
|
||||
render(<Page />);
|
||||
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("redirect");
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "other", name: "Other", role: "owner" } }));
|
||||
expect(screen.getByLabelText("Domain tab")).toHaveTextContent("tracking");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
// the tracking host its mailboxes use, whether its root redirects to the
|
||||
// company website, and the inbox vendor that holds it. A row opens the drawer.
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseString, domainFilter, domainTab } from "@/lib/browse-accounts-analytics";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useSearchParams } from "@/hooks/useSearchParams";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
@@ -69,10 +71,18 @@ export default function SendingDomainsPage() {
|
||||
const canView = usePermission("MANAGE_EMAILS");
|
||||
const profile = useUserProfile();
|
||||
const list = useSendingDomains(canView);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [filter, setFilter] = React.useState<Filter>("all");
|
||||
const [query, setQuery] = useBrowseState("emails.domains.search", "", browseString);
|
||||
const [filter, setFilter] = useBrowseState<Filter>("emails.domains.filter", "all", domainFilter);
|
||||
const [open, setOpen] = React.useState("");
|
||||
const [tab, setTab] = React.useState<DomainTab>("overview");
|
||||
const [tabIntent, setTabIntent] = React.useState<DomainTab | undefined>();
|
||||
const [tab, setTab] = useBrowseState<DomainTab>(`emails.domain.${open}.tab`, "overview", domainTab,
|
||||
tabIntent === undefined ? undefined : { initialOverride: tabIntent });
|
||||
React.useEffect(() => {
|
||||
if (tabIntent !== undefined) {
|
||||
setTab(tabIntent);
|
||||
setTabIntent(undefined);
|
||||
}
|
||||
}, [tabIntent, setTab]);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
|
||||
const [bulk, setBulk] = React.useState<BulkSetupIntent | null>(null);
|
||||
@@ -139,7 +149,9 @@ export default function SendingDomainsPage() {
|
||||
const d = searchParams.get("domain");
|
||||
if (!d) return;
|
||||
const t = searchParams.get("tab");
|
||||
setTab(t === "redirect" || t === "tracking" ? t : "overview");
|
||||
const nextTab = t === "redirect" || t === "tracking" ? t : "overview";
|
||||
setTabIntent(nextTab);
|
||||
if (open === d.toLowerCase()) setTab(nextTab);
|
||||
setOpen(d.toLowerCase());
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
@@ -150,10 +162,11 @@ export default function SendingDomainsPage() {
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
}, [searchParams, setSearchParams, open, setTab]);
|
||||
|
||||
const openDomain = (d: string, t: DomainTab = "overview") => {
|
||||
setTab(t);
|
||||
const openDomain = (d: string, t?: DomainTab) => {
|
||||
setTabIntent(t);
|
||||
if (open === d && t !== undefined) setTab(t);
|
||||
setOpen(d);
|
||||
};
|
||||
const addMailboxes = () => profile.setAddEmail(true);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { RiFireLine, RiMoreLine } from "@remixicon/react";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseString, mailboxSort, mailboxTab, type MailboxTab } from "@/lib/browse-accounts-analytics";
|
||||
import { useSearchParams } from "@/hooks/useSearchParams";
|
||||
import useMailboxTagFilter from "@/hooks/useMailboxTagFilter";
|
||||
import groupMailboxes from "@/lib/groupMailboxes";
|
||||
@@ -130,13 +132,15 @@ export default function AddressesPage() {
|
||||
const confirm = useConfirm();
|
||||
const canView = usePermission("MANAGE_EMAILS");
|
||||
|
||||
const [query, setQuery] = React.useState<string>("");
|
||||
const [query, setQuery] = useBrowseState("emails.search", "", browseString);
|
||||
const organizationID = useAppStore((state) => state.currentOrganization?.id ?? "personal");
|
||||
const [tag, setTag] = useMailboxTagFilter(`${p.user.id}:${organizationID}`, p.user.tags);
|
||||
const emailsData = useEmails({ query, tag });
|
||||
const [selected, setSelected] = React.useState<string[]>([]);
|
||||
const [view, setView] = React.useState<string>("");
|
||||
const [viewTab, setViewTab] = React.useState<string>("overview");
|
||||
const [viewTab, setViewTab] = React.useState<MailboxTab | undefined>();
|
||||
const [tabIntentKey, setTabIntentKey] = React.useState(0);
|
||||
const consumeTabIntent = React.useCallback(() => setViewTab(undefined), []);
|
||||
const [removing, setRemoving] = React.useState(false);
|
||||
const [bulkStart, setBulkStart] = React.useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -302,8 +306,10 @@ export default function AddressesPage() {
|
||||
return out;
|
||||
}, [selected, boxStatusById]);
|
||||
|
||||
const openDetail = (id: string, tab: string = "overview") => {
|
||||
setViewTab(tab);
|
||||
const openDetail = (id: string, tab?: string) => {
|
||||
const parsed = mailboxTab.safeParse(tab);
|
||||
setViewTab(tab === undefined ? undefined : parsed.success ? parsed.data : "overview");
|
||||
setTabIntentKey((key) => key + 1);
|
||||
setView(id);
|
||||
};
|
||||
|
||||
@@ -360,7 +366,7 @@ export default function AddressesPage() {
|
||||
}
|
||||
|
||||
// Every mailbox page is loaded, so the headers sort in the browser.
|
||||
const [sort, setSort] = React.useState<MailboxSort | null>(null);
|
||||
const [sort, setSort] = useBrowseState<MailboxSort | null>("emails.sort", null, mailboxSort);
|
||||
const sortBy = (col: MailboxColumn) =>
|
||||
setSort((cur) =>
|
||||
cur?.by === col.id ? { by: col.id, reverse: !cur.reverse } : { by: col.id, reverse: !!col.sortAsc },
|
||||
@@ -647,7 +653,7 @@ export default function AddressesPage() {
|
||||
)}
|
||||
</PageBody>
|
||||
|
||||
<InboxDetails emails={emailsData.emails} view={view} setView={setView} initialTab={viewTab} canWarmup={canWarmup} />
|
||||
<InboxDetails emails={emailsData.emails} view={view} setView={setView} initialTab={viewTab} tabIntentKey={tabIntentKey} onTabIntentConsumed={consumeTabIntent} canWarmup={canWarmup} />
|
||||
|
||||
<SigninMigrationDialog
|
||||
open={migrationOpen}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// provider losing reputation. Live through the child tests' events.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { batchTab, batchSenderSort, batchSenderStatus, browseString, browseBoolean } from "@/lib/browse-accounts-analytics";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowLeftIcon, ArrowUpRightIcon, AtSignIcon, Grid3x3Icon, Loader2Icon, MailIcon, ServerIcon, SquareIcon } from "lucide-react";
|
||||
@@ -90,7 +92,7 @@ export default function PlacementBatchPage() {
|
||||
body={err?.status === 404 ? "It may belong to another workspace." : err ? buildError(err) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Detail batch={q.data} />
|
||||
<Detail key={q.data.id} batch={q.data} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -100,7 +102,7 @@ function Detail({ batch }: { batch: PlacementBatchDetail }) {
|
||||
const confirm = useConfirm();
|
||||
const cancel = useCancelPlacementBatch();
|
||||
const campaign = useCampaign(batch.campaign_id ?? "");
|
||||
const [tab, setTab] = React.useState<Tab>("mailboxes");
|
||||
const [tab, setTab] = useBrowseState<Tab>(`placement.batch.${batch.id}.tab`, "mailboxes", batchTab);
|
||||
const open = batchOpen(batch.status);
|
||||
const p = batch.progress;
|
||||
|
||||
@@ -289,9 +291,9 @@ const STATUS_ORDER: PlacementBatchSenderStatus[] = ["running", "queued", "deferr
|
||||
|
||||
function SendersTab({ batch }: { batch: PlacementBatchDetail }) {
|
||||
const navigate = useNavigate();
|
||||
const [sort, setSort] = React.useState<PlacementBatchSenderSort>("worst");
|
||||
const [status, setStatus] = React.useState<PlacementBatchSenderStatus | "">("");
|
||||
const [q, setQ] = React.useState("");
|
||||
const [sort, setSort] = useBrowseState<PlacementBatchSenderSort>(`placement.batch.${batch.id}.senders.sort`, "worst", batchSenderSort);
|
||||
const [status, setStatus] = useBrowseState<PlacementBatchSenderStatus | "">(`placement.batch.${batch.id}.senders.status`, "", batchSenderStatus);
|
||||
const [q, setQ] = useBrowseState(`placement.batch.${batch.id}.senders.search`, "", browseString);
|
||||
const debounced = useDebouncedValue(q.trim(), 300);
|
||||
const list = usePlacementBatchSenders(batch.id, { sort, status, q: debounced });
|
||||
const p = batch.progress;
|
||||
@@ -481,7 +483,7 @@ function GroupTable({ groups, label, empty }: { groups: PlacementBatchGroup[]; l
|
||||
|
||||
// Sending domain by recipient provider: the inbox rate in each cell.
|
||||
function Matrix({ batch }: { batch: PlacementBatchDetail }) {
|
||||
const [all, setAll] = React.useState(false);
|
||||
const [all, setAll] = useBrowseState(`placement.batch.${batch.id}.matrix.showAll`, false, browseBoolean);
|
||||
const cols = batch.recipients;
|
||||
if (cols.length === 0 || batch.matrix.length === 0) {
|
||||
return <p className="px-5 py-6 text-[12px] text-slate-400">No copy has a verdict yet.</p>;
|
||||
|
||||
@@ -13,6 +13,8 @@ import { DashboardImage } from "@/components/ui/dashboard-image";
|
||||
// validation. Read data: /analytics/accounts/:id and /analytics/warmup?email_id=.
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { mailboxTab, type MailboxTab } from "@/lib/browse-accounts-analytics";
|
||||
import { diagnosticSendingAllowed, diagnosticWarmupActive } from "@/lib/diagnosticParticipation";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import AdvisorStrip from "@/components/app/advisor/AdvisorStrip";
|
||||
@@ -333,7 +335,7 @@ function statusTone(status: string) {
|
||||
|
||||
/* ── tabs ─────────────────────── */
|
||||
|
||||
const TABS: { key: string; label: string; icon: LucideIcon }[] = [
|
||||
const TABS: { key: MailboxTab; label: string; icon: LucideIcon }[] = [
|
||||
{ key: "overview", label: "Overview", icon: GaugeIcon },
|
||||
{ key: "deliverability", label: "Deliverability", icon: MailCheckIcon },
|
||||
{ key: "analytics", label: "Analytics", icon: BarChart3Icon },
|
||||
@@ -350,13 +352,17 @@ export default function InboxDetails({
|
||||
emails,
|
||||
view,
|
||||
setView,
|
||||
initialTab = "overview",
|
||||
initialTab,
|
||||
tabIntentKey,
|
||||
onTabIntentConsumed,
|
||||
canWarmup = true,
|
||||
}: {
|
||||
emails: Inbox[] | null;
|
||||
view: string;
|
||||
setView: React.Dispatch<React.SetStateAction<string>>;
|
||||
initialTab?: string;
|
||||
initialTab?: MailboxTab;
|
||||
tabIntentKey?: number;
|
||||
onTabIntentConsumed?: () => void;
|
||||
canWarmup?: boolean;
|
||||
}) {
|
||||
const mailbox = emails?.find((e) => e.id === view) ?? null;
|
||||
@@ -381,7 +387,7 @@ export default function InboxDetails({
|
||||
transition={{ type: "spring", damping: 32, stiffness: 320 }}
|
||||
className="fixed right-0 top-0 z-50 h-full w-full sm:w-[640px] xl:w-[720px] bg-white border-l border-slate-200 shadow-[0_0_60px_-12px_rgba(15,23,42,0.3)] flex flex-col"
|
||||
>
|
||||
<Detail key={mailbox.id} mailbox={mailbox} onClose={close} initialTab={initialTab} canWarmup={canWarmup} />
|
||||
<Detail key={mailbox.id} mailbox={mailbox} onClose={close} initialTab={initialTab} tabIntentKey={tabIntentKey} onTabIntentConsumed={onTabIntentConsumed} canWarmup={canWarmup} />
|
||||
</motion.aside>
|
||||
</>
|
||||
)}
|
||||
@@ -400,8 +406,15 @@ const EDITABLE: (keyof Inbox)[] = [
|
||||
"test_mode", "test_send_enabled", "test_receive_enabled", "shared_daily_limit", "rolling_recipient_limit",
|
||||
];
|
||||
|
||||
function Detail({ mailbox, onClose, initialTab = "overview", canWarmup = true }: { mailbox: Inbox; onClose: () => void; initialTab?: string; canWarmup?: boolean }) {
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
function Detail({ mailbox, onClose, initialTab, tabIntentKey, onTabIntentConsumed, canWarmup = true }: { mailbox: Inbox; onClose: () => void; initialTab?: MailboxTab; tabIntentKey?: number; onTabIntentConsumed?: () => void; canWarmup?: boolean }) {
|
||||
const [tab, setTab] = useBrowseState<MailboxTab>(`emails.mailbox.${mailbox.id}.tab`, "overview", mailboxTab,
|
||||
initialTab === undefined ? undefined : { initialOverride: initialTab });
|
||||
React.useEffect(() => {
|
||||
if (initialTab !== undefined) {
|
||||
setTab(initialTab);
|
||||
onTabIntentConsumed?.();
|
||||
}
|
||||
}, [initialTab, tabIntentKey, onTabIntentConsumed, setTab]);
|
||||
const [form, setForm] = useState<Inbox>(mailbox);
|
||||
const update = (patch: Partial<Inbox>) => setForm((f) => ({ ...f, ...patch }));
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { ConfirmContext } from "@/hooks/context/confirm";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import type Inbox from "@/lib/api/models/app/emails/Inbox";
|
||||
import type { MailboxTab } from "@/lib/browse-accounts-analytics";
|
||||
import { BROWSE_STORAGE_PREFIX } from "@/lib/browseState";
|
||||
import InboxDetails from "./InboxDetails";
|
||||
|
||||
vi.mock("@/lib/api/client/Request", () => ({ default: () => new Promise(() => {}) }));
|
||||
vi.mock("@/hooks/PresenceProvider", () => ({ usePresenceResource: () => {} }));
|
||||
vi.mock("@/components/app/presence/ResourceViewers", () => ({ default: () => null }));
|
||||
vi.mock("@/components/app/advisor/AdvisorStrip", () => ({ default: () => null }));
|
||||
vi.mock("@/hooks/useCloudPool", () => ({ default: () => ({ connected: false, selfHosted: false, isEnrolled: () => false }) }));
|
||||
vi.mock("./SendingBehaviorTab", () => ({ default: () => <p>Sending behavior</p> }));
|
||||
|
||||
const mailbox: Inbox = {
|
||||
id: "a", email: "alice@a.test", name: "Alice", signature_plain: "", signature_html: "", signature_sync: false, signature_code: false,
|
||||
send_as_email: "", tags: [], provider: "smtp_imap", status: "active", last_synced_at: new Date(), campaign_limit: 50, min_wait_time: 60,
|
||||
reply_to: "", save_to_sent: false, tracking_domain: "", tracking_domain_verified: false,
|
||||
auth_state: "unknown", auth_spf: false, auth_dkim: false, auth_dmarc: false,
|
||||
warmup_base: 5, warmup_max: 20, warmup_increase: 2, warmup_reply_rate: 30, created_at: new Date(), updated_at: new Date(),
|
||||
};
|
||||
let client: QueryClient;
|
||||
function Page({ id = "a", initialTab, tabIntentKey = 0, onTabIntentConsumed }: { id?: string; initialTab?: MailboxTab; tabIntentKey?: number; onTabIntentConsumed?: () => void }) {
|
||||
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return (
|
||||
<QueryClientProvider client={client}>
|
||||
<UserContext.Provider value={profile}>
|
||||
<ConfirmContext.Provider value={{ show: vi.fn(), setLoading: vi.fn(), setShow: vi.fn() }}>
|
||||
<InboxDetails emails={[mailbox, { ...mailbox, id: "b", email: "bob@b.test" }]} view={id} setView={() => {}} initialTab={initialTab} tabIntentKey={tabIntentKey} onTabIntentConsumed={onTabIntentConsumed} />
|
||||
</ConfirmContext.Provider>
|
||||
</UserContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
const tab = (name: string) => screen.getByRole("button", { name });
|
||||
|
||||
describe("mailbox drawer browse persistence", () => {
|
||||
beforeEach(() => {
|
||||
HTMLElement.prototype.scrollBy = vi.fn();
|
||||
sessionStorage.clear();
|
||||
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores drawer tabs on remount and isolates mailbox resources", () => {
|
||||
const page = render(<Page />);
|
||||
fireEvent.click(tab("Analytics"));
|
||||
page.unmount();
|
||||
const refreshed = render(<Page />);
|
||||
expect(tab("Analytics")).toHaveAttribute("data-active", "true");
|
||||
refreshed.rerender(<Page id="b" />);
|
||||
expect(tab("Overview")).toHaveAttribute("data-active", "true");
|
||||
refreshed.rerender(<Page />);
|
||||
expect(tab("Analytics")).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
it("honors repeated explicit tab intents without discarding mailbox drafts", () => {
|
||||
const first = render(<Page />);
|
||||
fireEvent.click(tab("Sending"));
|
||||
first.unmount();
|
||||
const page = render(<Page initialTab="analytics" />);
|
||||
expect(tab("Analytics")).toHaveAttribute("data-active", "true");
|
||||
fireEvent.click(tab("Warmup"));
|
||||
fireEvent.change(screen.getByLabelText(/Shared daily send limit/), { target: { value: "80" } });
|
||||
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
||||
page.rerender(<Page initialTab="analytics" tabIntentKey={1} />);
|
||||
expect(tab("Analytics")).toHaveAttribute("data-active", "true");
|
||||
expect(screen.getByText("Unsaved changes")).toBeInTheDocument();
|
||||
fireEvent.click(tab("Warmup"));
|
||||
expect(screen.getByLabelText(/Shared daily send limit/)).toHaveValue(80);
|
||||
page.rerender(<Page initialTab="analytics" tabIntentKey={2} />);
|
||||
expect(tab("Analytics")).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
it("consumes explicit navigation so another workspace restores its own tab", () => {
|
||||
sessionStorage.setItem(`${BROWSE_STORAGE_PREFIX}account-user:other:emails.mailbox.a.tab`, JSON.stringify({ value: "sending" }));
|
||||
const consumed = vi.fn();
|
||||
const page = render(<Page initialTab="analytics" onTabIntentConsumed={consumed} />);
|
||||
expect(consumed).toHaveBeenCalledOnce();
|
||||
page.rerender(<Page />);
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "other", name: "Other", role: "owner" } }));
|
||||
expect(tab("Sending")).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
});
|
||||
@@ -381,6 +381,7 @@ function Dialog({
|
||||
|
||||
{n > 1 && (
|
||||
<PerDomainList
|
||||
browseScope="emails.domains.bulkSetup.list"
|
||||
rows={listRows}
|
||||
onChange={(d, patch) => setEach((prev) => ({ ...prev, [d]: { ...prev[d], ...patch } }))}
|
||||
onReset={(d) =>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// listing each domain with its own tracking host and redirect website, filled
|
||||
// from the shared value until someone types over it.
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseString, browseBoolean, domainListLimit } from "@/lib/browse-accounts-analytics";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ChevronDownIcon, RotateCcwIcon } from "lucide-react";
|
||||
import { SearchInput, TextInput } from "@/components/ui/field";
|
||||
@@ -51,19 +53,21 @@ function Tick({ checked, onToggle, label, disabled }: { checked: boolean; onTogg
|
||||
}
|
||||
|
||||
export function PerDomainList({
|
||||
browseScope,
|
||||
rows,
|
||||
onChange,
|
||||
onReset,
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
browseScope: string;
|
||||
rows: PerDomainRow[];
|
||||
onChange: (domain: string, patch: DomainOverride) => void;
|
||||
onReset: (domain: string) => void;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(defaultOpen);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [limit, setLimit] = React.useState(20);
|
||||
const [open, setOpen] = useBrowseState(`${browseScope}.expanded`, defaultOpen, browseBoolean);
|
||||
const [query, setQuery] = useBrowseState(`${browseScope}.search`, "", browseString);
|
||||
const [limit, setLimit] = useBrowseState(`${browseScope}.limit`, 20, domainListLimit);
|
||||
const changed = rows.filter((r) => r.custom).length;
|
||||
const problems = rows.filter((r) => (r.track && r.hostProblem) || (r.redirect && r.urlProblem)).length;
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import PickTable, { type PickItem } from "../import/PickTable";
|
||||
import { PerDomainList, type PerDomainRow } from "./PerDomainList";
|
||||
|
||||
function Owner({ children }: { children: ReactNode }) {
|
||||
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={profile}>{children}</UserContext.Provider>;
|
||||
}
|
||||
|
||||
const items: PickItem[] = [
|
||||
{ id: "a", email: "alice@a.test", name: "Alice", connected: true, group: "A" },
|
||||
{ id: "b", email: "bob@b.test", name: "Bob", connected: false, group: "B" },
|
||||
];
|
||||
|
||||
function Picker({ scope = "emails.import.vendor.a.pick", loading = false }: { scope?: string; loading?: boolean }) {
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
return <Owner><PickTable browseScope={scope} items={loading ? undefined : items} loading={loading} selected={selected} setSelected={setSelected} noun={{ one: "mailbox", many: "mailboxes" }} /></Owner>;
|
||||
}
|
||||
|
||||
const rows: PerDomainRow[] = Array.from({ length: 80 }, (_, i) => ({
|
||||
domain: `domain-${i}.test`, canTrack: false, track: false, host: "", redirect: false, url: "", urlPlaceholder: "", custom: false,
|
||||
}));
|
||||
function DomainList({ scope = "emails.domains.bulkSetup.list" }: { scope?: string }) {
|
||||
return <Owner><PerDomainList browseScope={scope} rows={rows} onChange={() => {}} onReset={() => {}} /></Owner>;
|
||||
}
|
||||
|
||||
describe("nested account browsing persistence", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores picker search, filter and group after loading/remount without restoring selected rows", () => {
|
||||
const picker = render(<Picker />);
|
||||
fireEvent.change(screen.getByPlaceholderText("Search mailboxes"), { target: { value: "alice" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Connected/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /^A\s?1$/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /alice@a.test/ }));
|
||||
picker.unmount();
|
||||
const refreshed = render(<Picker loading />);
|
||||
refreshed.rerender(<Picker />);
|
||||
expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue("alice");
|
||||
expect(screen.getByRole("button", { name: /^Connected/ })).toHaveClass("bg-sky-50");
|
||||
expect(screen.getByRole("button", { name: /^A\s?1$/ })).toHaveAttribute("aria-pressed", "true");
|
||||
expect(screen.getByRole("button", { name: /alice@a.test/ })).toHaveAttribute("aria-pressed", "false");
|
||||
expect(screen.queryByText("bob@b.test")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("isolates connection scopes and persists explicit picker clearing", () => {
|
||||
const picker = render(<Picker />);
|
||||
fireEvent.change(screen.getByPlaceholderText("Search mailboxes"), { target: { value: "alice" } });
|
||||
picker.rerender(<Picker scope="emails.import.vendor.b.pick" />);
|
||||
expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue("");
|
||||
picker.rerender(<Picker />);
|
||||
expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue("alice");
|
||||
fireEvent.change(screen.getByPlaceholderText("Search mailboxes"), { target: { value: "" } });
|
||||
picker.unmount();
|
||||
render(<Picker />);
|
||||
expect(screen.getByPlaceholderText("Search mailboxes")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("restores expanded domain lists, search and show-more without persisting editor values", () => {
|
||||
const list = render(<DomainList />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Change per domain/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Show more/ }));
|
||||
fireEvent.change(screen.getByPlaceholderText("Find a domain…"), { target: { value: "domain" } });
|
||||
list.unmount();
|
||||
const refreshed = render(<DomainList />);
|
||||
expect(screen.getByRole("button", { name: /^Change per domain/ })).toHaveAttribute("aria-expanded", "true");
|
||||
expect(screen.getByPlaceholderText("Find a domain…")).toHaveValue("domain");
|
||||
expect(screen.getByText("domain-69.test")).toBeInTheDocument();
|
||||
expect(screen.queryByText("domain-70.test")).not.toBeInTheDocument();
|
||||
refreshed.rerender(<DomainList scope="emails.import.file.domainChoices" />);
|
||||
expect(screen.getByRole("button", { name: /^Change per domain/ })).toHaveAttribute("aria-expanded", "false");
|
||||
refreshed.rerender(<DomainList />);
|
||||
expect(screen.getByPlaceholderText("Find a domain…")).toHaveValue("domain");
|
||||
act(() => useAppStore.setState({ currentOrganization: { id: "other", name: "Other", role: "owner" } }));
|
||||
expect(screen.getByRole("button", { name: /^Change per domain/ })).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
@@ -171,10 +171,12 @@ export function AllDomainsChoice({
|
||||
|
||||
/** The shared choice with each domain's own values in a dropdown under it. */
|
||||
export function DomainChoicesPanel({
|
||||
browseScope = "emails.import.file.domainChoices",
|
||||
infos,
|
||||
picks,
|
||||
setPicks,
|
||||
}: {
|
||||
browseScope?: string;
|
||||
infos: DomainInfo[];
|
||||
picks: DomainPicks;
|
||||
setPicks: React.Dispatch<React.SetStateAction<DomainPicks>>;
|
||||
@@ -226,6 +228,7 @@ export function DomainChoicesPanel({
|
||||
<AllDomainsChoice infos={infos} picks={picks} setPicks={setPicks} />
|
||||
{usable.length > 1 && (
|
||||
<PerDomainList
|
||||
browseScope={browseScope}
|
||||
rows={rows}
|
||||
onChange={(d, patch) => setPicks((prev) => ({ ...prev, each: { ...prev.each, [d]: { ...prev.each[d], ...patch } } }))}
|
||||
onReset={(d) =>
|
||||
@@ -243,11 +246,13 @@ export function DomainChoicesPanel({
|
||||
|
||||
/** The picked mailboxes' domains with their choices, for the vendor and grant wizards. */
|
||||
export function DomainChoicesSection({
|
||||
browseScope,
|
||||
infos,
|
||||
picks,
|
||||
setPicks,
|
||||
capped = 0,
|
||||
}: {
|
||||
browseScope: string;
|
||||
infos: DomainInfo[];
|
||||
picks: DomainPicks;
|
||||
setPicks: React.Dispatch<React.SetStateAction<DomainPicks>>;
|
||||
@@ -258,7 +263,7 @@ export function DomainChoicesSection({
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel className="mb-1.5">Domains</SectionLabel>
|
||||
<DomainChoicesPanel infos={infos} picks={picks} setPicks={setPicks} />
|
||||
<DomainChoicesPanel infos={infos} picks={picks} setPicks={setPicks} browseScope={browseScope} />
|
||||
{capped > 0 && (
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
{plural(capped, "more domain is", "more domains are")} not listed. Set them up on the Sending domains page after the
|
||||
|
||||
@@ -350,6 +350,7 @@ export default function MailboxImportWizard({
|
||||
)}
|
||||
{step === "run" && importId && (
|
||||
<RunStep
|
||||
key={importId}
|
||||
importId={importId}
|
||||
onDone={onDone}
|
||||
onAllowance={onAllowance}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// span several, checkbox rows and paging. The caller owns the selection so the
|
||||
// wizard's floating bar can act on it.
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseString, importPickFilter } from "@/lib/browse-accounts-analytics";
|
||||
import { ChevronLeftIcon, ChevronRightIcon, LayersIcon, MailIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { SearchInput } from "@/components/ui/field";
|
||||
import { CheckSquare } from "@/components/ui/check-square";
|
||||
@@ -45,6 +47,7 @@ export function ProviderBadge({ provider }: { provider?: PickItem["provider"] })
|
||||
}
|
||||
|
||||
export default function PickTable({
|
||||
browseScope,
|
||||
items,
|
||||
loading,
|
||||
fetching,
|
||||
@@ -57,6 +60,7 @@ export default function PickTable({
|
||||
loadingSteps,
|
||||
groupLabel = "Workspace",
|
||||
}: {
|
||||
browseScope: string;
|
||||
items: PickItem[] | undefined;
|
||||
/** Whose mailboxes are being listed, and what is happening while they are. */
|
||||
loadingLogo?: string;
|
||||
@@ -71,9 +75,9 @@ export default function PickTable({
|
||||
/** What PickItem.group names, for the switcher shown when rows span several. */
|
||||
groupLabel?: string;
|
||||
}) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [filter, setFilter] = React.useState<Filter>("all");
|
||||
const [group, setGroup] = React.useState<string | null>(null);
|
||||
const [query, setQuery] = useBrowseState(`${browseScope}.search`, "", browseString);
|
||||
const [filter, setFilter] = useBrowseState<Filter>(`${browseScope}.filter`, "all", importPickFilter);
|
||||
const [group, setGroup] = useBrowseState<string | null>(`${browseScope}.group`, null, browseString.nullable());
|
||||
const [page, setPage] = React.useState(0);
|
||||
|
||||
const all = React.useMemo(() => items ?? [], [items]);
|
||||
@@ -199,7 +203,7 @@ export default function PickTable({
|
||||
["connected", "Connected"],
|
||||
] as const
|
||||
)
|
||||
.filter(([key]) => key !== "moving" || counts.moving > 0)
|
||||
.filter(([key]) => key !== "moving" || counts.moving > 0 || filter === "moving")
|
||||
.map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// cause (optionally with a new password), a per-row fix, and Sign in for rows
|
||||
// that connect with Google or Microsoft sign-in.
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { importFilter } from "@/lib/browse-accounts-analytics";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast/headless";
|
||||
@@ -122,7 +124,7 @@ export default function RunStep({
|
||||
const [granted, setGranted] = React.useState<DomainGrant | null>(null);
|
||||
const consent = useMicrosoftAdminConsent(setGranted);
|
||||
|
||||
const [filter, setFilter] = React.useState<Filter>({ status: "", cause: "" });
|
||||
const [filter, setFilter] = useBrowseState<Filter>(`emails.import.${importId}.results.filter`, { status: "", cause: "" }, importFilter);
|
||||
const [cursors, setCursors] = React.useState<string[]>([""]);
|
||||
const [fixing, setFixing] = React.useState<number | null>(null);
|
||||
const applyFilter = (f: Filter) => {
|
||||
|
||||
@@ -314,6 +314,8 @@ export default function SourceImportWizard(props: SourceImportWizardProps) {
|
||||
</button>
|
||||
)}
|
||||
<PickTable
|
||||
key={`${source}:${sourceKey}`}
|
||||
browseScope={`emails.import.${source}.${sourceKey}.pick`}
|
||||
items={items}
|
||||
loading={itemsLoading}
|
||||
loadingLogo={itemsLoadingLogo}
|
||||
@@ -373,6 +375,7 @@ export default function SourceImportWizard(props: SourceImportWizardProps) {
|
||||
)}
|
||||
|
||||
<DomainChoicesSection
|
||||
browseScope={`emails.import.${source}.${sourceKey}.domainChoices`}
|
||||
infos={domainInfos}
|
||||
picks={domainPicks}
|
||||
setPicks={setDomainPicks}
|
||||
@@ -384,6 +387,7 @@ export default function SourceImportWizard(props: SourceImportWizardProps) {
|
||||
)}
|
||||
{current === "run" && importId && (
|
||||
<RunStep
|
||||
key={importId}
|
||||
importId={importId}
|
||||
onDone={onDone}
|
||||
onAllowance={onAllowance}
|
||||
|
||||
@@ -165,6 +165,7 @@ export default function SigninMigrationDialog({
|
||||
>
|
||||
{run ? (
|
||||
<RunStep
|
||||
key={run.importId}
|
||||
importId={run.importId}
|
||||
onDone={onClose}
|
||||
onImportAnother={() => setRun(null)}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// A mailbox's warmup deliverability: where its warmup mail landed, day by day
|
||||
// and per recipient provider, and the trailing inbox rate behind the list's
|
||||
// percentage. Live: WARMUP_PLACEMENT events refresh the query.
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { placementRange, placementGroupFilter } from "@/lib/browse-accounts-analytics";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import { DitherRing } from "@/components/ui/dither";
|
||||
import { Loading } from "@/components/loader";
|
||||
@@ -35,8 +37,8 @@ const POOL_STATE_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function MailboxPlacementTab({ mailboxId, poolHealth }: { mailboxId: string; poolHealth?: WarmupHealthInfo }) {
|
||||
const [range, setRange] = useState<RangeKey>("30d");
|
||||
const [group, setGroup] = useState<GroupFilter>("all");
|
||||
const [range, setRange] = useBrowseState<RangeKey>(`emails.mailbox.${mailboxId}.placement.range`, "30d", placementRange);
|
||||
const [group, setGroup] = useBrowseState<GroupFilter>(`emails.mailbox.${mailboxId}.placement.group`, "all", placementGroupFilter);
|
||||
const days = RANGES.find((r) => r.key === range)?.days ?? 30;
|
||||
const { from, to } = useMemo(() => utcWindow(days), [days]);
|
||||
const q = useWarmupPlacement(mailboxId, from, to);
|
||||
@@ -171,7 +173,7 @@ export default function MailboxPlacementTab({ mailboxId, poolHealth }: { mailbox
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<Eyebrow>By recipient provider</Eyebrow>
|
||||
</div>
|
||||
<ProviderBreakdown providers={report.providers} />
|
||||
<ProviderBreakdown providers={report.providers} browseScope={`emails.mailbox.${mailboxId}.placement`} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { UserContext } from "@/hooks/context/user";
|
||||
import { useAppStore } from "@/stores/useAppStore";
|
||||
import type { PlacementHost, PlacementProvider } from "@/lib/api/models/app/analytics/WarmupPlacement";
|
||||
import { ProviderBreakdown } from "./PlacementCharts";
|
||||
|
||||
const counts = { sent: 10, delivered: 10, inbox: 8, tabs: 0, spam: 2, rescued: 0, unconfirmed: 0, inbox_rate: 80, spam_rate: 20 };
|
||||
const providers: PlacementProvider[] = [{
|
||||
...counts, group: "google", hosts: ["gmail", "google_workspace"].map((host): PlacementHost => ({ ...counts, host })),
|
||||
}];
|
||||
function Breakdown({ scope = "emails.mailbox.a.placement", loading = false }: { scope?: string; loading?: boolean }) {
|
||||
const profile = { user: { id: "account-user" } } as ComponentProps<typeof UserContext.Provider>["value"];
|
||||
return <UserContext.Provider value={profile}><ProviderBreakdown browseScope={scope} providers={loading ? [] : providers} /></UserContext.Provider>;
|
||||
}
|
||||
|
||||
describe("provider expansion browse persistence", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
useAppStore.setState({ currentOrganization: { id: "workspace", name: "Workspace", role: "owner" } });
|
||||
});
|
||||
|
||||
it("restores expansion after remount and metadata loading, isolated from other placement panels", () => {
|
||||
const panel = render(<Breakdown />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
panel.unmount();
|
||||
const refreshed = render(<Breakdown loading />);
|
||||
refreshed.rerender(<Breakdown />);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
|
||||
refreshed.rerender(<Breakdown scope="deliverability.placement" />);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false");
|
||||
refreshed.rerender(<Breakdown />);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true");
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
refreshed.unmount();
|
||||
render(<Breakdown />);
|
||||
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@
|
||||
// deliverability page: the rate badge, daily landing columns, the rolling
|
||||
// inbox-rate trend, the provider breakdown and the filter controls.
|
||||
import React, { useState } from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { placementGroup } from "@/lib/browse-accounts-analytics";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { DitherColumns, DitherStack, type DitherTone } from "@/components/ui/dither";
|
||||
import { EmptyChart } from "@/components/ui/charts";
|
||||
@@ -298,8 +300,8 @@ export function RateSpark({ values, width = 96, height = 22 }: { values: (number
|
||||
|
||||
// One row per recipient group; expanding it lists the hosts inside
|
||||
// (Gmail vs Google Workspace, Outlook.com vs Microsoft 365).
|
||||
export function ProviderBreakdown({ providers }: { providers: PlacementProvider[] }) {
|
||||
const [open, setOpen] = useState<PlacementGroup | null>(null);
|
||||
export function ProviderBreakdown({ providers, browseScope }: { providers: PlacementProvider[]; browseScope: string }) {
|
||||
const [open, setOpen] = useBrowseState<PlacementGroup | null>(`${browseScope}.providerExpanded`, null, placementGroup.nullable());
|
||||
if (providers.length === 0) {
|
||||
return <p className="px-5 py-6 text-[12px] text-slate-400 text-center">No deliveries at any provider in this window.</p>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Workspace warmup placement on the deliverability page: inbox vs spam over
|
||||
// time, per recipient provider, and every mailbox ranked worst first.
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseBoolean, placementGroupFilter } from "@/lib/browse-accounts-analytics";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import { EmptyBlock, SectionBar, Stat, StatStrip } from "@/components/layout/Page";
|
||||
@@ -25,9 +27,9 @@ export default function WarmupPlacementSection({ days }: { days: number }) {
|
||||
const { from, to } = useMemo(() => utcWindow(days), [days]);
|
||||
const q = useWarmupPlacement(undefined, from, to);
|
||||
const report = q.data;
|
||||
const [group, setGroup] = useState<GroupFilter>("all");
|
||||
const [attentionOnly, setAttentionOnly] = useState(false);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const [group, setGroup] = useBrowseState<GroupFilter>("deliverability.placement.group", "all", placementGroupFilter);
|
||||
const [attentionOnly, setAttentionOnly] = useBrowseState("deliverability.placement.attentionOnly", false, browseBoolean);
|
||||
const [showAll, setShowAll] = useBrowseState("deliverability.placement.showAll", false, browseBoolean);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const groups = useMemo<PlacementGroup[]>(
|
||||
@@ -121,7 +123,7 @@ export default function WarmupPlacementSection({ days }: { days: number }) {
|
||||
</div>
|
||||
|
||||
<SectionBar label="Warmup placement by provider" count={report.providers.length || undefined} />
|
||||
<ProviderBreakdown providers={report.providers} />
|
||||
<ProviderBreakdown providers={report.providers} browseScope="deliverability.placement" />
|
||||
<p className="px-5 py-3 text-[11.5px] text-slate-500 leading-relaxed border-t border-slate-200/60">
|
||||
Warmup partner selection reads these numbers per mail host: a mailbox landing in spam at one host is sent
|
||||
fewer partners there while the rate stays high, and more again once it recovers. It is never cut off
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// allowance, and a seed never warms up or sends campaign mail.
|
||||
|
||||
import React from "react";
|
||||
import useBrowseState from "@/hooks/useBrowseState";
|
||||
import { browseString } from "@/lib/browse-accounts-analytics";
|
||||
import { InboxIcon, Loader2Icon } from "lucide-react";
|
||||
import toast from "react-hot-toast/headless";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
@@ -26,7 +28,7 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
export default function SeedInboxes() {
|
||||
const seeds = usePlacementSeeds();
|
||||
const canManage = usePermission("MANAGE_EMAILS");
|
||||
const [q, setQ] = React.useState("");
|
||||
const [q, setQ] = useBrowseState("placement.seeds.search", "", browseString);
|
||||
const rows = seeds.data ?? [];
|
||||
const needle = q.trim().toLowerCase();
|
||||
const shown = needle ? rows.filter((r) => r.email.toLowerCase().includes(needle) || r.label.toLowerCase().includes(needle)) : rows;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const browseString = z.string();
|
||||
export const browseBoolean = z.boolean();
|
||||
export const analyticsRange = z.enum(["7d", "30d", "90d"]);
|
||||
export const mailboxTab = z.enum(["overview", "deliverability", "analytics", "warmup", "sending", "settings"]);
|
||||
export type MailboxTab = z.infer<typeof mailboxTab>;
|
||||
export const domainTab = z.enum(["overview", "tracking", "redirect"]);
|
||||
export const domainFilter = z.enum(["all", "attention", "vendor"]);
|
||||
export const mailboxSort = z.strictObject({
|
||||
by: z.enum(["mailbox", "status", "sent", "warmup", "inbox", "health"]),
|
||||
reverse: z.boolean(),
|
||||
}).nullable();
|
||||
export const analyticsHiddenMetrics = z.array(z.enum(["sent", "opens", "clicks", "replies"]))
|
||||
.max(3).refine((values) => new Set(values).size === values.length);
|
||||
export const deliverabilityHiddenMetrics = z.array(z.enum(["bounces", "complaints", "opens", "replies", "sent"]))
|
||||
.max(4).refine((values) => new Set(values).size === values.length);
|
||||
export const deliverabilityView = z.strictObject({
|
||||
range: analyticsRange,
|
||||
hidden: z.array(z.enum(["chart", "totals", "placement", "providers", "mailboxes", "campaigns"]))
|
||||
.max(6).refine((values) => new Set(values).size === values.length),
|
||||
});
|
||||
export const placementRange = z.enum(["7d", "14d", "30d", "90d"]);
|
||||
export const placementGroup = z.enum(["google", "microsoft", "yahoo", "other"]);
|
||||
export const placementGroupFilter = z.enum(["all", "google", "microsoft", "yahoo", "other"]);
|
||||
export const batchTab = z.enum(["mailboxes", "domains", "providers", "recipients"]);
|
||||
export const batchSenderSort = z.enum(["worst", "best", "email", "status"]);
|
||||
export const batchSenderStatus = z.enum(["", "queued", "deferred", "running", "completed", "skipped", "failed", "cancelled"]);
|
||||
export const importFilter = z.strictObject({
|
||||
status: z.enum(["", "queued", "running", "connected", "updated", "skipped", "failed", "needs_signin", "cancelled"]),
|
||||
cause: z.string(),
|
||||
}).refine((value) => !value.cause || value.status === "failed");
|
||||
export const importPickFilter = z.enum(["all", "new", "moving", "connected"]);
|
||||
export const domainListLimit = z.number().finite().int().positive();
|
||||
Reference in New Issue
Block a user