mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-15 10:21:41 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - People talk to agents in chat, and agents create routines and other scheduled work from that chat > - Paperclip does not record the person's time zone, so a chat agent cannot read "every weekday at nine" or "today" in the person's zone > - The agent then saves schedules in UTC, and the person gets the work at the wrong hour > - This pull request saves the browser time zone with the person's personal preferences and gives it to chat agents in the task prompt > - The benefit is that schedules and dates from chat match the person's local time ## Linked Issues or Issue Description **What happened** A person in America/Chicago wrote in chat: "Every weekday at nine in the morning, summarize Launch for me." The agent saved the routine schedule `0 9 * * 1-5` with the UTC time zone. It replied that it used UTC because the person's time zone is not recorded. **Expected behavior** The agent knows the person's time zone. It saves one enabled `0 9 * * 1-5` schedule with the time zone America/Chicago. Its reply names the zone. **Steps to reproduce** 1. Open the board in a browser with the time zone America/Chicago. 2. Open a chat with an agent. 3. Send "Every weekday at nine in the morning, summarize Launch for me." 4. Look at the routine trigger that the agent creates. Its time zone is UTC. ## What Changed - Database: add the nullable `time_zone` column to `user_workspace_preferences` (migration `0468`). This is an instance-wide personal preference by user, as AGENTS.md section 5 permits. - Shared: `WorkspacePreference` has `timeZone`. `updateWorkspacePreferenceSchema` accepts an optional IANA `timeZone` (max 64 characters, checked with `Intl.DateTimeFormat`). `coordinatorWorkspaceEnabled` is now optional. The request must contain at least one field. - Server: the preference service returns `timeZone` and updates only the fields in the request. The activity log entry stays the same. The OpenAPI entry uses the shared schema. - UI: a new `useBrowserTimeZoneSync` hook in `Layout` saves the browser time zone one time when it is different from the saved zone. It is silent and best-effort. It uses the selected company as the audit context. - Prompt: for a conversation (any adapter), the task context reads the chat owner's saved zone. Recovery keeps the zone that the first turn admitted. The chat block adds one line only when the zone is known: `The person's time zone is America/Chicago (from their browser). Read times and dates they give, such as "nine in the morning" or "today", in that zone, and save schedules such as routine triggers with that timezone; say the zone when you report a schedule.` ## Verification - `cd server && npx vitest run src/__tests__/workspace-preferences.test.ts src/__tests__/workspace-preferences-routes.test.ts src/services/heartbeat/task-markdown.test.ts src/__tests__/heartbeat-task-context.test.ts src/__tests__/openapi-routes.test.ts src/services/native-runtime/runner-api.test.ts` - `cd ui && npx vitest run src/hooks/useBrowserTimeZoneSync.test.tsx src/hooks/useCoordinatorWorkspaceEnabled.test.tsx src/components/Layout.test.tsx` - Red/green: without the task-context and task-markdown changes, the two new prompt tests fail. With the changes, they pass. - `pnpm check:token-gates` is clean. `tsc --noEmit` passes for `packages/shared` and `packages/db`. - Manual: open the board in a browser with a non-UTC zone. Then ask a chat agent to create a weekday routine at nine in the morning. The trigger uses the browser zone. ## Risks - Low risk. The migration adds one nullable column. - The PUT body is now more flexible: `coordinatorWorkspaceEnabled` is optional. An empty body still gets a 400 response. - The prompt grows by one line only for conversations with a known zone. - The saved zone changes when the person uses a browser in a different zone. The latest browser wins. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1343 lines
41 KiB
TypeScript
1343 lines
41 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { createRoot } from "react-dom/client";
|
|
import { flushSync } from "react-dom";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { Layout } from "./Layout";
|
|
|
|
const classicWorkspace = vi.hoisted(() => ({ enabled: true }));
|
|
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({
|
|
useCoordinatorWorkspaceEnabled: () => ({ enabled: !classicWorkspace.enabled, loaded: true, userId: "viewer" }),
|
|
}));
|
|
const browserTimeZoneSync = vi.hoisted(() => vi.fn());
|
|
vi.mock("@/hooks/useBrowserTimeZoneSync", () => ({ useBrowserTimeZoneSync: browserTimeZoneSync }));
|
|
|
|
const mockDesignSandbox = vi.hoisted(() => ({ enabled: false }));
|
|
vi.mock("@/context/DesignSandboxContext", () => ({ useDesignSandbox: () => mockDesignSandbox.enabled }));
|
|
vi.mock("./DesignSandboxSidebar", () => ({ DesignSandboxSidebar: () => <div>Proposed sidebar</div> }));
|
|
|
|
const mockHealthApi = vi.hoisted(() => ({
|
|
get: vi.fn(),
|
|
}));
|
|
|
|
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
|
getGeneral: vi.fn(),
|
|
getExperimental: vi.fn(),
|
|
}));
|
|
|
|
const mockNavigate = vi.hoisted(() => vi.fn());
|
|
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
|
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
|
const mockCompanyState = vi.hoisted(() => ({
|
|
companies: [{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" }],
|
|
selectedCompany: { id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
|
selectedCompanyId: "company-1",
|
|
}));
|
|
const mockPluginSlots = vi.hoisted(() => ({
|
|
slots: [] as Array<Record<string, unknown>>,
|
|
}));
|
|
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
|
const mockPluginSlotContexts = vi.hoisted(
|
|
() => [] as Array<Record<string, unknown>>,
|
|
);
|
|
const mockSetPeeking = vi.hoisted(() => vi.fn());
|
|
const mockSetForceCollapsed = vi.hoisted(() => vi.fn());
|
|
const mockSidebarState = vi.hoisted(() => ({
|
|
sidebarOpen: true,
|
|
isMobile: false,
|
|
collapsed: false,
|
|
peeking: false,
|
|
}));
|
|
let currentPathname = "/PAP/dashboard";
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Outlet: () => <div>Outlet content</div>,
|
|
useLocation: () => ({
|
|
pathname: currentPathname,
|
|
search: "",
|
|
hash: "",
|
|
state: null,
|
|
}),
|
|
useNavigate: () => mockNavigate,
|
|
useNavigationType: () => "PUSH",
|
|
useParams: () => {
|
|
const [firstSegment, secondSegment, entityId] = currentPathname
|
|
.split("/")
|
|
.filter(Boolean);
|
|
return {
|
|
companyPrefix: firstSegment ?? "PAP",
|
|
pluginRoutePath: secondSegment,
|
|
agentId: secondSegment === "agents" ? entityId : undefined,
|
|
routineId: secondSegment === "routines" ? entityId : undefined,
|
|
};
|
|
},
|
|
}));
|
|
|
|
vi.mock("./Sidebar", () => ({
|
|
Sidebar: ({ contentHeaderControls }: { contentHeaderControls?: boolean }) => (
|
|
<div data-content-header-controls={String(contentHeaderControls ?? false)}>
|
|
Main company nav
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("./CompanySettingsSidebar", () => ({
|
|
CompanySettingsSidebar: () => <div>Company settings sidebar</div>,
|
|
}));
|
|
|
|
vi.mock("./AppsSidebar", () => ({
|
|
AppsSidebar: () => <div>Apps sidebar</div>,
|
|
}));
|
|
|
|
vi.mock("./AgentContextualSidebar", () => ({
|
|
AgentContextualSidebar: ({ agentRef }: { agentRef: string }) => (
|
|
<div>Agent sidebar {agentRef}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("./RoutineContextualSidebar", () => ({
|
|
RoutineContextualSidebar: ({ routineId }: { routineId: string }) => (
|
|
<div>Routine sidebar {routineId}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("./SkillsContextualSidebar", () => ({
|
|
SkillsContextualSidebar: () => <div>Skills sidebar</div>,
|
|
}));
|
|
|
|
vi.mock("./AppConnectionSidebar", () => ({
|
|
AppDetailSidebar: (
|
|
props:
|
|
| { kind: "connection"; connectionId: string }
|
|
| { kind: "application"; applicationId: string },
|
|
) => (
|
|
<div>
|
|
{props.kind === "connection"
|
|
? `App detail sidebar connection ${props.connectionId}`
|
|
: `App detail sidebar application ${props.applicationId}`}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("./BreadcrumbBar", () => ({
|
|
BreadcrumbBar: () => <div>Breadcrumbs</div>,
|
|
}));
|
|
|
|
vi.mock("./PropertiesPanel", () => ({
|
|
PropertiesPanel: () => null,
|
|
}));
|
|
|
|
// Overlay account/company lifecycle has its own integration test. These tests
|
|
// exercise route navigation with intentionally minimal context providers.
|
|
vi.mock("./PluginAppShellOverlays", () => ({
|
|
PluginAppShellOverlays: () => null,
|
|
}));
|
|
|
|
vi.mock("./CommandPalette", () => ({
|
|
CommandPalette: () => null,
|
|
}));
|
|
|
|
vi.mock("./NewIssueDialog", () => ({
|
|
NewIssueDialog: () => null,
|
|
}));
|
|
|
|
vi.mock("./NewProjectDialog", () => ({
|
|
NewProjectDialog: () => null,
|
|
}));
|
|
|
|
vi.mock("./NewGoalDialog", () => ({
|
|
NewGoalDialog: () => null,
|
|
}));
|
|
|
|
vi.mock("./NewAgentDialog", () => ({
|
|
NewAgentDialog: () => null,
|
|
}));
|
|
|
|
vi.mock("./KeyboardShortcutsCheatsheet", () => ({
|
|
KeyboardShortcutsCheatsheet: () => null,
|
|
}));
|
|
|
|
vi.mock("./ToastViewport", () => ({
|
|
ToastViewport: () => null,
|
|
}));
|
|
|
|
vi.mock("./AnnouncementWell", () => ({
|
|
AnnouncementWell: () => <div data-announcement-well />,
|
|
}));
|
|
|
|
vi.mock("./GitHubStarPrompt", () => ({
|
|
GitHubStarPrompt: () => null,
|
|
}));
|
|
|
|
vi.mock("./MobileBottomNav", () => ({
|
|
MobileBottomNav: () => null,
|
|
}));
|
|
|
|
vi.mock("./WorktreeBanner", () => ({
|
|
WorktreeBanner: () => null,
|
|
}));
|
|
|
|
vi.mock("./DevRestartBanner", () => ({
|
|
DevRestartBanner: () => null,
|
|
}));
|
|
|
|
vi.mock("./SidebarAccountMenu", () => ({
|
|
SidebarAccountMenu: () => <div>Account menu</div>,
|
|
}));
|
|
|
|
vi.mock("../plugins/slots", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("../plugins/slots")>(
|
|
"../plugins/slots",
|
|
);
|
|
return {
|
|
resolveRouteSidebarSlot: actual.resolveRouteSidebarSlot,
|
|
usePluginSlots: (params: Record<string, unknown>) => {
|
|
mockUsePluginSlots(params);
|
|
return {
|
|
slots: mockPluginSlots.slots,
|
|
isLoading: false,
|
|
errorMessage: null,
|
|
};
|
|
},
|
|
PluginSlotMount: ({
|
|
slot,
|
|
context,
|
|
className,
|
|
}: {
|
|
slot: { displayName: string };
|
|
context: Record<string, unknown>;
|
|
className?: string;
|
|
}) => {
|
|
mockPluginSlotContexts.push(context);
|
|
return (
|
|
<div data-plugin-slot-class={className}>
|
|
Plugin route sidebar: {slot.displayName}
|
|
</div>
|
|
);
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("../context/DialogContext", () => ({
|
|
useDialogState: () => ({ newIssueOpen: false, newProjectOpen: false, newGoalOpen: false, newAgentOpen: false }),
|
|
useDialog: () => ({
|
|
openNewIssue: vi.fn(),
|
|
openOnboarding: vi.fn(),
|
|
}),
|
|
useDialogActions: () => ({
|
|
openNewIssue: vi.fn(),
|
|
openOnboarding: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../context/PanelContext", () => ({
|
|
usePanel: () => ({
|
|
togglePanelVisible: vi.fn(),
|
|
setPanelVisible: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../context/CompanyContext", () => ({
|
|
useCompany: () => ({
|
|
companies: mockCompanyState.companies,
|
|
loading: false,
|
|
selectedCompany: mockCompanyState.selectedCompany,
|
|
selectedCompanyId: mockCompanyState.selectedCompanyId,
|
|
selectionSource: "manual",
|
|
setSelectedCompanyId: mockSetSelectedCompanyId,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../context/SidebarContext", () => ({
|
|
useSidebar: () => ({
|
|
sidebarOpen: mockSidebarState.sidebarOpen,
|
|
setSidebarOpen: mockSetSidebarOpen,
|
|
toggleSidebar: vi.fn(),
|
|
toggleCollapsed: vi.fn(),
|
|
collapsed: mockSidebarState.collapsed,
|
|
peeking: mockSidebarState.peeking,
|
|
setPeeking: mockSetPeeking,
|
|
setForceCollapsed: mockSetForceCollapsed,
|
|
isMobile: mockSidebarState.isMobile,
|
|
routeRequestsCollapsed: false,
|
|
setRouteRequestsCollapsed: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../hooks/useKeyboardShortcuts", () => ({
|
|
useKeyboardShortcuts: () => undefined,
|
|
}));
|
|
|
|
vi.mock("../hooks/useCompanyPageMemory", () => ({
|
|
useCompanyPageMemory: () => undefined,
|
|
}));
|
|
|
|
vi.mock("../api/health", () => ({
|
|
healthApi: mockHealthApi,
|
|
}));
|
|
|
|
vi.mock("../api/instanceSettings", () => ({
|
|
instanceSettingsApi: mockInstanceSettingsApi,
|
|
}));
|
|
|
|
vi.mock("../lib/company-selection", () => ({
|
|
shouldSyncCompanySelectionFromRoute: () => false,
|
|
// No bounce in the shared harness: these tests exercise layout chrome, not
|
|
// archived-company routing (covered by company-selection unit tests and the
|
|
// archived-company-url e2e).
|
|
resolveArchivedCompanyBounce: () => null,
|
|
}));
|
|
|
|
vi.mock("../lib/main-content-focus", () => ({
|
|
scheduleMainContentFocus: () => () => undefined,
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
async function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> = undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
await result;
|
|
}
|
|
|
|
async function flushReact() {
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
function renderLayoutView(root: ReturnType<typeof createRoot>, queryClient: QueryClient) {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Layout />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
describe("Layout", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
classicWorkspace.enabled = true;
|
|
mockDesignSandbox.enabled = false;
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
currentPathname = "/PAP/dashboard";
|
|
mockCompanyState.companies = [
|
|
{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
|
];
|
|
mockCompanyState.selectedCompany = {
|
|
id: "company-1",
|
|
issuePrefix: "PAP",
|
|
name: "Paperclip",
|
|
};
|
|
mockCompanyState.selectedCompanyId = "company-1";
|
|
mockHealthApi.get.mockResolvedValue({
|
|
status: "ok",
|
|
deploymentMode: "authenticated",
|
|
deploymentExposure: "private",
|
|
version: "1.2.3",
|
|
});
|
|
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
|
censorUsernameInLogs: false,
|
|
});
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableApps: true,
|
|
});
|
|
mockPluginSlots.slots = [];
|
|
mockPluginSlotContexts.length = 0;
|
|
mockSidebarState.sidebarOpen = true;
|
|
mockSidebarState.isMobile = false;
|
|
mockSidebarState.collapsed = false;
|
|
mockSidebarState.peeking = false;
|
|
mockSetPeeking.mockClear();
|
|
mockSetForceCollapsed.mockClear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("uses the standard workspace presentation when Classic is off", async () => {
|
|
classicWorkspace.enabled = false;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
try {
|
|
await act(async () => renderLayoutView(root, queryClient));
|
|
await flushReact();
|
|
expect(container.querySelector(".coordinator-workspace")).not.toBeNull();
|
|
classicWorkspace.enabled = true;
|
|
await act(async () => renderLayoutView(root, queryClient));
|
|
expect(container.querySelector(".coordinator-workspace")).toBeNull();
|
|
} finally { await act(async () => root.unmount()); queryClient.clear(); }
|
|
});
|
|
|
|
it("does not render the deployment explainer in the shared layout", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(mockHealthApi.get).toHaveBeenCalled();
|
|
expect(container.textContent).toContain("Breadcrumbs");
|
|
expect(container.textContent).toContain("Outlet content");
|
|
expect(container.querySelectorAll("[data-announcement-well]")).toHaveLength(1);
|
|
expect(container.textContent).not.toContain("Company rail");
|
|
expect(container.textContent).not.toContain("Authenticated private");
|
|
expect(container.textContent).not.toContain(
|
|
"Sign-in is required and this instance is intended for private-network access.",
|
|
);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("scopes the Streamlined task-detail surface while preserving balanced horizontal gutters", async () => {
|
|
currentPathname = "/PAP/issues/PAP-1";
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableApps: true,
|
|
enableStreamlinedUi: true,
|
|
});
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(
|
|
container.querySelector(".streamlined-task-detail-surface"),
|
|
).not.toBeNull();
|
|
expect(
|
|
container.querySelector("#main-content")?.classList.contains("pt-0"),
|
|
).toBe(true);
|
|
expect(
|
|
container.querySelector("#main-content")?.classList.contains("md:pt-0"),
|
|
).toBe(true);
|
|
expect(
|
|
container.querySelector("#main-content")?.classList.contains("p-4"),
|
|
).toBe(true);
|
|
expect(
|
|
container.querySelector("#main-content")?.classList.contains("md:p-6"),
|
|
).toBe(true);
|
|
expect(
|
|
container.querySelector("#main-content")?.classList.contains("pr-0"),
|
|
).toBe(false);
|
|
expect(
|
|
container.querySelector("#main-content")?.classList.contains("md:pr-0"),
|
|
).toBe(false);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("collapses atomically when the pointer is still over the sidebar (no re-peek) — PAP-10676", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
const renderLayout = async () => {
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
};
|
|
|
|
// The SidebarShell overlay panel carries the peek mouse handlers.
|
|
const panel = () =>
|
|
[...container.querySelectorAll<HTMLElement>("div")].find(
|
|
(el) =>
|
|
el.className.includes("inset-y-0") &&
|
|
el.className.includes("overflow-hidden"),
|
|
);
|
|
const hover = (el: HTMLElement) => {
|
|
// React derives onMouseEnter from a mouseover crossing in from outside.
|
|
el.dispatchEvent(
|
|
new MouseEvent("mouseover", {
|
|
bubbles: true,
|
|
relatedTarget: document.body,
|
|
}),
|
|
);
|
|
};
|
|
|
|
// Expanded, then hover the panel so the pointer is registered as inside.
|
|
await renderLayout();
|
|
const expandedPanel = panel();
|
|
expect(expandedPanel).toBeTruthy();
|
|
await act(async () => {
|
|
hover(expandedPanel!);
|
|
});
|
|
|
|
// Collapse while the pointer is still over the panel.
|
|
mockSidebarState.collapsed = true;
|
|
await renderLayout();
|
|
// The peek is cancelled atomically on collapse.
|
|
expect(mockSetPeeking).toHaveBeenCalledWith(false);
|
|
|
|
// A lingering/spurious hover while collapsed must NOT re-open the peek.
|
|
mockSetPeeking.mockClear();
|
|
const railPanel = panel();
|
|
await act(async () => {
|
|
hover(railPanel!);
|
|
});
|
|
await act(async () => {
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
});
|
|
expect(mockSetPeeking).not.toHaveBeenCalledWith(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("opens the peek when hovering a collapsed rail (positive control for the hover sim)", async () => {
|
|
mockSidebarState.collapsed = true;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
|
|
const panel = [...container.querySelectorAll<HTMLElement>("div")].find(
|
|
(el) =>
|
|
el.className.includes("inset-y-0") &&
|
|
el.className.includes("overflow-hidden"),
|
|
);
|
|
expect(panel).toBeTruthy();
|
|
await act(async () => {
|
|
panel!.dispatchEvent(
|
|
new MouseEvent("mouseover", {
|
|
bubbles: true,
|
|
relatedTarget: document.body,
|
|
}),
|
|
);
|
|
});
|
|
await act(async () => {
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
});
|
|
// A normal collapsed-rail hover (not just-collapsed) opens the peek.
|
|
expect(mockSetPeeking).toHaveBeenCalledWith(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("retains settings navigation and route content beside the proposed sandbox sidebar", async () => {
|
|
currentPathname = "/PAP/company/settings/access";
|
|
mockDesignSandbox.enabled = true;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
await act(async () => { root.render(<QueryClientProvider client={queryClient}><Layout /></QueryClientProvider>); });
|
|
await flushReact();
|
|
expect(container.textContent).toContain("Proposed sidebar");
|
|
expect(container.textContent).toContain("Company settings sidebar");
|
|
expect(container.textContent).toContain("Outlet content");
|
|
expect(container.textContent).not.toContain("Main company nav");
|
|
await act(async () => { root.unmount(); });
|
|
});
|
|
|
|
it("replaces the app sidebar with settings navigation on Streamlined settings routes", async () => {
|
|
currentPathname = "/PAP/company/settings/access";
|
|
mockPluginSlots.slots = [
|
|
{
|
|
type: "page",
|
|
id: "company-page",
|
|
displayName: "Company Page",
|
|
exportName: "CompanyPage",
|
|
routePath: "company",
|
|
pluginId: "plugin-1",
|
|
pluginKey: "fake-plugin",
|
|
pluginDisplayName: "Fake Plugin",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
{
|
|
type: "routeSidebar",
|
|
id: "company-sidebar",
|
|
displayName: "Company Route Sidebar",
|
|
exportName: "CompanySidebar",
|
|
routePath: "company",
|
|
pluginId: "plugin-1",
|
|
pluginKey: "fake-plugin",
|
|
pluginDisplayName: "Fake Plugin",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
];
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Company settings sidebar");
|
|
expect(container.textContent).not.toContain("Main company nav");
|
|
const secondaryRail = container.querySelector("[data-secondary-sidebar]");
|
|
expect(secondaryRail).not.toBeNull();
|
|
expect(secondaryRail?.classList.contains("w-60")).toBe(false);
|
|
expect(container.textContent).not.toContain("Company rail");
|
|
expect(container.textContent).not.toContain("Instance sidebar");
|
|
expect(container.textContent).not.toContain("Plugin route sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the global sidebar beside legacy settings navigation when Streamlined UI is off", async () => {
|
|
currentPathname = "/PAP/company/settings/access";
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableApps: true,
|
|
enableStreamlinedUi: false,
|
|
});
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Main company nav");
|
|
expect(container.textContent).toContain("Company settings sidebar");
|
|
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("renders a mobile company settings selector on company settings routes", async () => {
|
|
currentPathname = "/PAP/company/settings/secrets";
|
|
mockSidebarState.isMobile = true;
|
|
mockSidebarState.sidebarOpen = false;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const selector = container.querySelector("select");
|
|
expect(selector).not.toBeNull();
|
|
expect(selector?.value).toBe("secrets");
|
|
const selectorText = selector?.textContent?.toLowerCase() ?? "";
|
|
expect(selectorText).toContain("general");
|
|
expect(selectorText).toContain("export");
|
|
expect(selectorText).toContain("import");
|
|
expect(selectorText).toContain("members");
|
|
// Invites live on a tab of the Members page now, so the selector no
|
|
// longer carries a standalone entry for them.
|
|
expect(selectorText).not.toContain("invites");
|
|
expect(selectorText).toContain("secrets");
|
|
expect(selectorText).toContain("profile");
|
|
expect(selectorText).toContain("environments");
|
|
expect(selectorText).toContain("plugins");
|
|
expect(selectorText).not.toContain("instance general");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("replaces the company nav on instance settings routes", async () => {
|
|
currentPathname = "/PAP/company/settings/instance/general";
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Company settings sidebar");
|
|
expect(container.textContent).not.toContain("Main company nav");
|
|
expect(container.textContent).not.toContain("Company rail");
|
|
expect(container.textContent).not.toContain("Plugin route sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it.each(["/PAP/company/export", "/PAP/company/import"])(
|
|
"replaces the company nav with the shared settings sidebar on %s",
|
|
async (pathname) => {
|
|
currentPathname = pathname;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Company settings sidebar");
|
|
expect(container.textContent).not.toContain("Main company nav");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps the company nav beside Apps navigation on legacy tools routes", async () => {
|
|
currentPathname = "/PAP/tools/runtime";
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Apps sidebar");
|
|
expect(container.textContent).toContain("Main company nav");
|
|
expect(container.textContent).not.toContain("Company settings sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("mounts the Apps secondary sidebar regardless of the retired experimental flag", async () => {
|
|
currentPathname = "/PAP/apps";
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableApps: false,
|
|
});
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Apps sidebar");
|
|
expect(container.textContent).toContain("Main company nav");
|
|
const secondaryRail = container.querySelector("[data-secondary-sidebar]");
|
|
expect(secondaryRail?.classList.contains("w-60")).toBe(true);
|
|
expect(secondaryRail?.classList.contains("shrink-0")).toBe(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the Apps sidebar on the M8 advanced-setup tabs", async () => {
|
|
currentPathname = "/PAP/apps/advanced/paste-config";
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Apps sidebar");
|
|
expect(container.textContent).toContain("Main company nav");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the Apps sidebar on legacy developer tabs behind the Advanced door", async () => {
|
|
currentPathname = "/PAP/apps/advanced/runtime";
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Apps sidebar");
|
|
expect(container.textContent).toContain("Main company nav");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
// Reserved Apps subroutes are not connection ids. They must keep the
|
|
// top-level Apps sidebar, never mount a detail sidebar for a phantom app.
|
|
it.each(["browse", "connections", "vercel-connect", "review", "chat"])(
|
|
"keeps the Apps sidebar on the %s surface",
|
|
async (route) => {
|
|
currentPathname = `/PAP/apps/${route}`;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Apps sidebar");
|
|
expect(container.textContent).toContain("Main company nav");
|
|
expect(container.textContent).not.toContain("App detail sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
},
|
|
);
|
|
|
|
it("keeps the Apps sidebar on the gateways list and detail routes", async () => {
|
|
for (const pathname of [
|
|
"/PAP/apps/gateways",
|
|
"/PAP/apps/gateways/gw-1/overview",
|
|
]) {
|
|
currentPathname = pathname;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Apps sidebar");
|
|
expect(container.textContent).toContain("Main company nav");
|
|
expect(container.textContent).not.toContain("App detail sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
}
|
|
});
|
|
|
|
it("uses the app connection sidebar on app detail routes", async () => {
|
|
currentPathname = "/PAP/apps/conn-1/permissions";
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain(
|
|
"App detail sidebar connection conn-1",
|
|
);
|
|
expect(container.textContent).toContain("Main company nav");
|
|
expect(container.textContent).not.toContain("Apps sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("uses the app detail sidebar on not-connected app routes", async () => {
|
|
currentPathname = "/PAP/apps/app/app-1/permissions";
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain(
|
|
"App detail sidebar application app-1",
|
|
);
|
|
expect(container.textContent).toContain("Main company nav");
|
|
expect(container.textContent).not.toContain("Apps sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps global navigation beside Agent and Routine details", async () => {
|
|
async function renderAt(pathname: string) {
|
|
currentPathname = pathname;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
return root;
|
|
}
|
|
|
|
for (const [pathname, sidebarText] of [
|
|
["/PAP/agents/briefing-analyst/skills", "Agent sidebar briefing-analyst"],
|
|
[
|
|
"/PAP/agents/briefing-analyst/runs/run-1",
|
|
"Agent sidebar briefing-analyst",
|
|
],
|
|
["/PAP/routines/routine-1/overview", "Routine sidebar routine-1"],
|
|
] as const) {
|
|
const root = await renderAt(pathname);
|
|
expect(container.textContent).toContain(sidebarText);
|
|
expect(container.textContent).toContain("Main company nav");
|
|
const secondaryRail = container.querySelector("[data-secondary-sidebar]");
|
|
expect(secondaryRail?.classList.contains("w-60")).toBe(true);
|
|
expect(secondaryRail?.classList.contains("bg-background")).toBe(true);
|
|
const breadcrumb = Array.from(container.querySelectorAll("div")).find(
|
|
(element) => element.textContent === "Breadcrumbs",
|
|
);
|
|
expect(breadcrumb).toBeDefined();
|
|
expect(
|
|
breadcrumb && secondaryRail
|
|
? breadcrumb.compareDocumentPosition(secondaryRail) &
|
|
Node.DOCUMENT_POSITION_FOLLOWING
|
|
: 0,
|
|
).not.toBe(0);
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
container.innerHTML = "";
|
|
}
|
|
});
|
|
|
|
it("keeps global navigation with agent configuration navigation in the legacy shell", async () => {
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableApps: true,
|
|
enableStreamlinedUi: false,
|
|
});
|
|
|
|
for (const pathname of [
|
|
"/PAP/agents/briefing-analyst/skills",
|
|
"/PAP/routines/routine-1/overview",
|
|
]) {
|
|
currentPathname = pathname;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Main company nav");
|
|
if (pathname.includes("/agents/")) expect(container.textContent).toContain("Agent sidebar");
|
|
else expect(container.textContent).not.toContain("Agent sidebar");
|
|
expect(container.textContent).not.toContain("Routine sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
container.innerHTML = "";
|
|
}
|
|
});
|
|
|
|
it("uses the global rail without secondary Skills navigation in the streamlined shell", async () => {
|
|
for (const path of ["/PAP/skills", "/PAP/skills?tab=discover", "/PAP/skills/studio", "/PAP/skills/sources"]) {
|
|
currentPathname = path;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
await act(async () => { root.render(<QueryClientProvider client={queryClient}><Layout /></QueryClientProvider>); });
|
|
await flushReact();
|
|
await flushReact();
|
|
expect(container.textContent).toContain("Main company nav");
|
|
expect(container.textContent).not.toContain("Skills sidebar");
|
|
await act(async () => root.unmount());
|
|
container.innerHTML = "";
|
|
}
|
|
});
|
|
|
|
it("keeps the global rail beside Skills navigation in the legacy shell", async () => {
|
|
currentPathname = "/PAP/skills/studio";
|
|
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
|
enableApps: true,
|
|
enableStreamlinedUi: false,
|
|
});
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Main company nav");
|
|
expect(container.textContent).toContain("Skills sidebar");
|
|
expect(mockSetForceCollapsed).toHaveBeenCalledWith(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps Agent and Routine collection routes in global navigation", async () => {
|
|
for (const pathname of [
|
|
"/PAP/agents/all",
|
|
"/PAP/agents/new",
|
|
"/PAP/routines",
|
|
]) {
|
|
currentPathname = pathname;
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Main company nav");
|
|
expect(container.textContent).not.toContain("Agent sidebar");
|
|
expect(container.textContent).not.toContain("Routine sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
container.innerHTML = "";
|
|
}
|
|
});
|
|
|
|
it("renders a route-scoped plugin sidebar for a matching plugin page route", async () => {
|
|
currentPathname = "/PAP/wiki";
|
|
mockPluginSlots.slots = [
|
|
{
|
|
type: "page",
|
|
id: "wiki-page",
|
|
displayName: "Wiki Page",
|
|
exportName: "WikiPage",
|
|
routePath: "wiki",
|
|
pluginId: "plugin-1",
|
|
pluginKey: "wiki-plugin",
|
|
pluginDisplayName: "Wiki Plugin",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
{
|
|
type: "routeSidebar",
|
|
id: "wiki-route-sidebar",
|
|
displayName: "Wiki Sidebar",
|
|
exportName: "WikiSidebar",
|
|
routePath: "wiki",
|
|
pluginId: "plugin-1",
|
|
pluginKey: "wiki-plugin",
|
|
pluginDisplayName: "Wiki Plugin",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
];
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain(
|
|
"Plugin route sidebar: Wiki Sidebar",
|
|
);
|
|
expect(
|
|
container.querySelector("[data-plugin-slot-class='min-h-0 flex-1']"),
|
|
).not.toBeNull();
|
|
expect(container.textContent).not.toContain("Main company nav");
|
|
expect(container.textContent).not.toContain("Company settings sidebar");
|
|
expect(container.textContent).not.toContain("Instance sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the route-scoped plugin sidebar on nested plugin page routes", async () => {
|
|
currentPathname = "/PAP/wiki/page/templates";
|
|
mockPluginSlots.slots = [
|
|
{
|
|
type: "page",
|
|
id: "wiki-page",
|
|
displayName: "Wiki Page",
|
|
exportName: "WikiPage",
|
|
routePath: "wiki",
|
|
pluginId: "plugin-1",
|
|
pluginKey: "wiki-plugin",
|
|
pluginDisplayName: "Wiki Plugin",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
{
|
|
type: "routeSidebar",
|
|
id: "wiki-route-sidebar",
|
|
displayName: "Wiki Sidebar",
|
|
exportName: "WikiSidebar",
|
|
routePath: "wiki",
|
|
pluginId: "plugin-1",
|
|
pluginKey: "wiki-plugin",
|
|
pluginDisplayName: "Wiki Plugin",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
];
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(mockUsePluginSlots).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
companyId: "company-1",
|
|
enabled: true,
|
|
}),
|
|
);
|
|
expect(container.textContent).toContain(
|
|
"Plugin route sidebar: Wiki Sidebar",
|
|
);
|
|
expect(container.textContent).not.toContain("Main company nav");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("uses the route company context for plugin route sidebars on the first render", async () => {
|
|
currentPathname = "/ALT/wiki";
|
|
mockCompanyState.companies = [
|
|
{ id: "company-1", issuePrefix: "PAP", name: "Paperclip" },
|
|
{ id: "company-2", issuePrefix: "ALT", name: "Alternate" },
|
|
];
|
|
mockCompanyState.selectedCompany = {
|
|
id: "company-1",
|
|
issuePrefix: "PAP",
|
|
name: "Paperclip",
|
|
};
|
|
mockCompanyState.selectedCompanyId = "company-1";
|
|
mockPluginSlots.slots = [
|
|
{
|
|
type: "page",
|
|
id: "wiki-page",
|
|
displayName: "Wiki Page",
|
|
exportName: "WikiPage",
|
|
routePath: "wiki",
|
|
pluginId: "plugin-1",
|
|
pluginKey: "wiki-plugin",
|
|
pluginDisplayName: "Wiki Plugin",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
{
|
|
type: "routeSidebar",
|
|
id: "wiki-route-sidebar",
|
|
displayName: "Wiki Sidebar",
|
|
exportName: "WikiSidebar",
|
|
routePath: "wiki",
|
|
pluginId: "plugin-1",
|
|
pluginKey: "wiki-plugin",
|
|
pluginDisplayName: "Wiki Plugin",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
];
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(mockUsePluginSlots).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
companyId: "company-2",
|
|
enabled: true,
|
|
}),
|
|
);
|
|
expect(mockPluginSlotContexts).toContainEqual({
|
|
companyId: "company-2",
|
|
companyPrefix: "ALT",
|
|
});
|
|
expect(mockPluginSlotContexts).not.toContainEqual({
|
|
companyId: "company-1",
|
|
companyPrefix: "PAP",
|
|
});
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the normal company sidebar when a plugin page route is ambiguous", async () => {
|
|
currentPathname = "/PAP/wiki";
|
|
mockPluginSlots.slots = [
|
|
{
|
|
type: "page",
|
|
id: "wiki-page-a",
|
|
displayName: "Wiki Page A",
|
|
exportName: "WikiPageA",
|
|
routePath: "wiki",
|
|
pluginId: "plugin-1",
|
|
pluginKey: "wiki-plugin-a",
|
|
pluginDisplayName: "Wiki Plugin A",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
{
|
|
type: "page",
|
|
id: "wiki-page-b",
|
|
displayName: "Wiki Page B",
|
|
exportName: "WikiPageB",
|
|
routePath: "wiki",
|
|
pluginId: "plugin-2",
|
|
pluginKey: "wiki-plugin-b",
|
|
pluginDisplayName: "Wiki Plugin B",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
{
|
|
type: "routeSidebar",
|
|
id: "wiki-route-sidebar",
|
|
displayName: "Wiki Sidebar",
|
|
exportName: "WikiSidebar",
|
|
routePath: "wiki",
|
|
pluginId: "plugin-1",
|
|
pluginKey: "wiki-plugin-a",
|
|
pluginDisplayName: "Wiki Plugin A",
|
|
pluginVersion: "1.0.0",
|
|
},
|
|
];
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Main company nav");
|
|
expect(container.textContent).not.toContain("Plugin route sidebar");
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
async function renderLayoutRoot(): Promise<{
|
|
root: ReturnType<typeof createRoot>;
|
|
rootEl: HTMLElement;
|
|
}> {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
await act(async () => {
|
|
renderLayoutView(root, queryClient);
|
|
});
|
|
await flushReact();
|
|
const rootEl = container.firstElementChild as HTMLElement;
|
|
return { root, rootEl };
|
|
}
|
|
|
|
it("clips horizontal overflow on the mobile layout root so the viewport can't scroll sideways", async () => {
|
|
mockSidebarState.isMobile = true;
|
|
mockSidebarState.sidebarOpen = false;
|
|
const { root, rootEl } = await renderLayoutRoot();
|
|
|
|
expect(rootEl.tagName).toBe("DIV");
|
|
expect(rootEl.className).toContain("bg-background");
|
|
// The mobile root must clip horizontal overflow to prevent a stray wide
|
|
// descendant from making the whole viewport scroll sideways. clip (not
|
|
// hidden) keeps overflow-y visible so body scroll keeps working.
|
|
expect(rootEl.classList.contains("overflow-x-clip")).toBe(true);
|
|
expect(rootEl.classList.contains("overflow-hidden")).toBe(false);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("clips overflow on the desktop layout root", async () => {
|
|
mockSidebarState.isMobile = false;
|
|
const { root, rootEl } = await renderLayoutRoot();
|
|
|
|
expect(rootEl.className).toContain("bg-background");
|
|
expect(rootEl.classList.contains("overflow-clip")).toBe(true);
|
|
|
|
await act(async () => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|