mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The main navigation and task detail view are core operator surfaces. > - Several controls used different hover states, popover layouts, and spacing rules. > - Recent task actions also needed a compact menu and correct inbox archive behavior. > - These differences made the interface feel inconsistent and caused some content to look crowded or clipped. > - This pull request aligns these surfaces with the Paperclip design tokens and current interaction patterns. > - The benefit is a simpler and more consistent operator experience in light and dark modes. ## Linked Issues or Issue Description **What happened?** Profile and organization popovers used inconsistent layouts. Navigation controls used different hover and selected backgrounds. Task warnings and the composer could crowd nearby content. Archiving a recent task could also remove it from more than the inbox. **Expected behavior** Popover menus should use the same compact visual language. Navigation controls should share readable hover and selected tokens. Task detail content should keep consistent spacing. Archiving should hide a task from the inbox while keeping it in the task list. **Steps to reproduce** 1. Open the main sidebar in light or dark mode. 2. Open the profile and organization menus. 3. Hover navigation items, the organization trigger, the profile trigger, and the feedback flag. 4. Open a task with a warning banner and a long thread. 5. Use the recent task overflow menu and archive a task. **Paperclip version or commit** Reproduced on `master` before this branch. **Deployment mode** Local dev (`pnpm dev`). ## What Changed - Rebuilt the profile and organization popovers with compact token-based layouts. - Matched organization popover width and alignment to the profile popover. - Unified sidebar hover and selected states in light and dark modes. - Added a recent task overflow menu with rename, archive, and pause or restart actions. - Kept archived tasks in the task list while removing them from the inbox. - Improved warning banner and composer spacing in task detail views. - Added and updated focused UI tests for the changed behavior. ## Verification - `pnpm check:token-gates` passed. - `pnpm --filter @paperclipai/ui typecheck` passed. - The seven affected UI test files passed with 216 tests. - `pnpm --filter @paperclipai/ui build` passed. - GitHub CI passed the full build, typecheck and release registry, general test, serialized server, canary dry-run, and end-to-end matrices. - Greptile reviewed commit `6e296be85` at 5/5 with no outstanding actionable findings. ## Risks - Risk is limited to sidebar presentation, recent task actions, and task detail layout. - The recent task archive action now follows inbox-only archive semantics. - No database schema or public API contract changed. > I checked [`ROADMAP.md`](ROADMAP.md). This pull request does not duplicate planned core work. ## Model Used - OpenAI Codex, GPT-5.6. The model used high reasoning, tool use, and code execution. The context window size was not exposed. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Scott Tong <scott@scottsmbpm5max.lan> Co-authored-by: Paperclip <noreply@paperclip.ing>
827 lines
28 KiB
TypeScript
827 lines
28 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import type { ReactNode } from "react";
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import { SidebarCompanyMenu } from "./SidebarCompanyMenu";
|
|
import { SidebarCompanyMenu as SidebarCompanyMenuProduction } from "./SidebarCompanyMenu.production";
|
|
|
|
const mockAuthApi = vi.hoisted(() => ({
|
|
getSession: vi.fn(),
|
|
signInEmail: vi.fn(),
|
|
signUpEmail: vi.fn(),
|
|
getProfile: vi.fn(),
|
|
updateProfile: vi.fn(),
|
|
signOut: vi.fn(),
|
|
}));
|
|
const mockNavigate = vi.hoisted(() => vi.fn());
|
|
const mockOpenOnboarding = vi.hoisted(() => vi.fn());
|
|
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
|
|
const mockSetSidebarOpen = vi.hoisted(() => vi.fn());
|
|
const mockLocation = vi.hoisted(() => ({ pathname: "/PAP/dashboard" }));
|
|
const mockSidebarPreferencesApi = vi.hoisted(() => ({
|
|
getCompanyOrder: vi.fn(),
|
|
updateCompanyOrder: vi.fn(),
|
|
}));
|
|
const mockCloudApi = vi.hoisted(() => ({
|
|
listStacks: vi.fn(),
|
|
}));
|
|
const mockNavigateTopLevel = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/api/auth", () => ({
|
|
authApi: mockAuthApi,
|
|
}));
|
|
|
|
vi.mock("@/api/cloud", () => ({
|
|
cloudApi: mockCloudApi,
|
|
}));
|
|
|
|
vi.mock("@/lib/browserNavigation", () => ({
|
|
navigateTopLevel: mockNavigateTopLevel,
|
|
}));
|
|
|
|
vi.mock("@/api/sidebarPreferences", () => ({
|
|
sidebarPreferencesApi: mockSidebarPreferencesApi,
|
|
}));
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ children, to, ...props }: { children: ReactNode; to: string }) => (
|
|
<a href={to} {...props}>{children}</a>
|
|
),
|
|
useLocation: () => mockLocation,
|
|
useNavigate: () => mockNavigate,
|
|
}));
|
|
|
|
// Overridable so the list-unavailable branch can be exercised; null means "use
|
|
// the default three companies below".
|
|
const mockCompanyState = vi.hoisted(() => ({
|
|
companies: null as unknown[] | null,
|
|
companyListUnavailable: false,
|
|
retryCompanies: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/context/CompanyContext", () => ({
|
|
useCompany: () => ({
|
|
companyListUnavailable: mockCompanyState.companyListUnavailable,
|
|
retryCompanies: mockCompanyState.retryCompanies,
|
|
companies: mockCompanyState.companies ?? [
|
|
{
|
|
id: "company-1",
|
|
issuePrefix: "PAP",
|
|
name: "Acme Labs",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: "company-2",
|
|
issuePrefix: "STR",
|
|
name: "Strata",
|
|
status: "active",
|
|
},
|
|
{
|
|
id: "company-3",
|
|
issuePrefix: "ANA",
|
|
name: "Anachronist Wiki",
|
|
status: "active",
|
|
},
|
|
],
|
|
selectedCompany: {
|
|
id: "company-1",
|
|
issuePrefix: "PAP",
|
|
name: "Acme Labs",
|
|
logoUrl: "/api/assets/logo-asset-1/content",
|
|
status: "active",
|
|
},
|
|
setSelectedCompanyId: mockSetSelectedCompanyId,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/context/DialogContext", () => ({
|
|
useDialogActions: () => ({
|
|
openOnboarding: mockOpenOnboarding,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("./CompanyPatternIcon", () => ({
|
|
CompanyPatternIcon: ({ companyName, logoUrl }: { companyName: string; logoUrl?: string | null }) => (
|
|
<span aria-hidden="true" data-logo-url={logoUrl ?? undefined}>{companyName.slice(0, 1)}</span>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../context/SidebarContext", () => ({
|
|
useSidebar: () => ({
|
|
isMobile: false,
|
|
setSidebarOpen: mockSetSidebarOpen,
|
|
}),
|
|
}));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
function act(callback: () => void) {
|
|
flushSync(callback);
|
|
}
|
|
|
|
async function flushReact() {
|
|
await Promise.resolve();
|
|
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
|
}
|
|
|
|
const CLOUD_HEALTH = {
|
|
status: "ok" as const,
|
|
cloud: {
|
|
managed: true as const,
|
|
managedBy: "paperclip-cloud" as const,
|
|
stackSlug: "acme-labs",
|
|
cloudBaseUrl: "https://cloud.example.test",
|
|
},
|
|
};
|
|
|
|
const CLOUD_STACKS = {
|
|
stacks: [
|
|
{
|
|
displayName: "Acme Labs",
|
|
stackSlug: "acme-labs",
|
|
primaryHost: "acme-labs.example.test",
|
|
lifecycleState: "active",
|
|
sleepState: "awake",
|
|
role: "owner",
|
|
isCurrent: true,
|
|
},
|
|
{
|
|
displayName: "Strata Systems",
|
|
stackSlug: "strata",
|
|
primaryHost: "strata.example.test",
|
|
lifecycleState: "active",
|
|
sleepState: "asleep",
|
|
role: "member",
|
|
isCurrent: false,
|
|
},
|
|
],
|
|
};
|
|
|
|
describe("SidebarCompanyMenu", () => {
|
|
let container: HTMLDivElement;
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
mockAuthApi.getSession.mockResolvedValue({
|
|
session: { id: "session-1", userId: "user-1" },
|
|
user: {
|
|
id: "user-1",
|
|
name: "Jane Example",
|
|
email: "jane@example.com",
|
|
},
|
|
});
|
|
mockAuthApi.signOut.mockResolvedValue(undefined);
|
|
mockCloudApi.listStacks.mockResolvedValue(CLOUD_STACKS);
|
|
mockSidebarPreferencesApi.getCompanyOrder.mockResolvedValue({
|
|
orderedIds: ["company-1", "company-2", "company-3"],
|
|
updatedAt: null,
|
|
});
|
|
mockSidebarPreferencesApi.updateCompanyOrder.mockResolvedValue({
|
|
orderedIds: ["company-1", "company-2", "company-3"],
|
|
updatedAt: null,
|
|
});
|
|
mockLocation.pathname = "/PAP/dashboard";
|
|
mockCompanyState.companies = null;
|
|
mockCompanyState.companyListUnavailable = false;
|
|
});
|
|
|
|
afterEach(() => {
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function renderMenu(options: { cloud?: boolean; health?: unknown } = {}) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
// CloudAccessGate owns the health fetch in the app; seeding the cache is how
|
|
// useCloudInstance sees a cloud-managed instance under test.
|
|
if (options.cloud || options.health) {
|
|
queryClient.setQueryData(queryKeys.health, options.health ?? CLOUD_HEALTH);
|
|
}
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarCompanyMenu />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
return { root, queryClient };
|
|
}
|
|
|
|
async function openMenu(ariaLabel: string) {
|
|
const trigger = container.querySelector(`button[aria-label="${ariaLabel}"]`);
|
|
expect(trigger).not.toBeNull();
|
|
act(() => {
|
|
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
}
|
|
|
|
// This menu is the one the app renders, so it is the only place a customer can
|
|
// act on a failed company list. Saying "No companies" there states something
|
|
// about the account that a failed request cannot support, and leaves the tab
|
|
// with no way back short of a browser reload.
|
|
it("offers a way back when the company list could not be loaded", async () => {
|
|
mockCompanyState.companies = [];
|
|
mockCompanyState.companyListUnavailable = true;
|
|
|
|
const { root } = renderMenu();
|
|
await flushReact();
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("Couldn't load organizations");
|
|
expect(document.body.textContent).not.toContain("No organizations");
|
|
|
|
const retryItem = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
|
|
(item) => item.textContent?.includes("Try again"),
|
|
);
|
|
expect(retryItem).not.toBeUndefined();
|
|
|
|
act(() => {
|
|
retryItem?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
|
retryItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockCompanyState.retryCompanies).toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("still reports an account that owns no companies as empty, not broken", async () => {
|
|
mockCompanyState.companies = [];
|
|
mockCompanyState.companyListUnavailable = false;
|
|
|
|
const { root } = renderMenu();
|
|
await flushReact();
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("No organizations");
|
|
expect(document.body.textContent).not.toContain("Couldn't load organizations");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("uses company-centric create copy without the chat flag", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarCompanyMenu />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const trigger = container.querySelector('button[aria-label="Open Acme Labs organization switcher"]');
|
|
expect(trigger).not.toBeNull();
|
|
expect(trigger?.classList).toContain("px-4");
|
|
expect(trigger?.classList).toContain("has-[>svg]:px-4");
|
|
expect(trigger?.classList).toContain("hover:bg-sidebar-accent");
|
|
expect(trigger?.classList).toContain("hover:text-sidebar-accent-foreground");
|
|
expect(trigger?.classList).toContain("dark:hover:bg-sidebar-accent");
|
|
expect(trigger?.classList).toContain("dark:hover:text-sidebar-accent-foreground");
|
|
expect(trigger?.classList).not.toContain("hover:bg-background");
|
|
expect(trigger?.classList).not.toContain("dark:hover:bg-background");
|
|
expect(trigger?.classList).not.toContain("dark:hover:bg-accent/50");
|
|
act(() => {
|
|
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("Create organization");
|
|
expect(document.body.textContent).not.toContain("Add company...");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("shows the requested company actions and signs out through the dropdown", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
// The invite shortcut waits for the health response before it shows, so
|
|
// resolve it here the way CloudAccessGate does in the app.
|
|
queryClient.setQueryData(queryKeys.health, { status: "ok" });
|
|
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarCompanyMenu />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(container.textContent).toContain("Acme Labs");
|
|
|
|
const trigger = container.querySelector('button[aria-label="Open Acme Labs organization switcher"]');
|
|
expect(trigger).not.toBeNull();
|
|
|
|
act(() => {
|
|
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("Organizations");
|
|
expect(document.body.textContent).toContain("Edit");
|
|
expect(document.body.textContent).toContain("Strata");
|
|
expect(document.body.textContent).toContain("ANA");
|
|
expect(document.body.textContent).toContain("Create organization");
|
|
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
|
expect(document.body.textContent).not.toContain("Company settings");
|
|
expect(document.body.textContent).toContain("Sign out");
|
|
|
|
const signOutButton = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Sign out"));
|
|
expect(signOutButton).toBeTruthy();
|
|
|
|
act(() => {
|
|
signOutButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockAuthApi.signOut).toHaveBeenCalledTimes(1);
|
|
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
|
|
expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true);
|
|
expect(document.body.textContent).not.toContain("Organizations");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("shows the production-shell invite shortcut when no surface is hidden", async () => {
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
queryClient.setQueryData(queryKeys.health, { status: "ok" });
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarCompanyMenuProduction />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs company switcher");
|
|
|
|
expect(document.body.textContent).toContain("Invite people to Acme Labs");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("hides the production-shell invite shortcut when the operator hides the invites surface", async () => {
|
|
// The production shell (streamlined UI disabled) must honor
|
|
// PAPERCLIP_HIDDEN_SETTINGS like the streamlined menu — this is the knob
|
|
// Paperclip Cloud uses to drop the shortcut on its managed stacks.
|
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
queryClient.setQueryData(queryKeys.health, { status: "ok", hiddenSettings: ["company.invites"] });
|
|
const root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarCompanyMenuProduction />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs company switcher");
|
|
|
|
expect(document.body.textContent).toContain("Organizations");
|
|
expect(document.body.textContent).not.toContain("Invite people");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the invite shortcut out of the menu until hidden settings resolve", async () => {
|
|
// No health data in the cache: the hidden-settings set is unknown, so the
|
|
// shortcut must not flash in and then disappear once the response lands.
|
|
const { root } = renderMenu();
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("Organizations");
|
|
expect(document.body.textContent).not.toContain("Invite people");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("hides the invite shortcut when the operator hides the invites surface", async () => {
|
|
const { root } = renderMenu({
|
|
health: { status: "ok", hiddenSettings: ["company.invites"] },
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("Organizations");
|
|
expect(document.body.textContent).not.toContain("Invite people");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("hides the invite shortcut when the operator hides the members page", async () => {
|
|
const { root } = renderMenu({
|
|
health: { status: "ok", hiddenSettings: ["company.members"] },
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("Organizations");
|
|
expect(document.body.textContent).not.toContain("Invite people");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("toggles company order editing without selecting a company", async () => {
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarCompanyMenu />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const trigger = container.querySelector('button[aria-label="Open Acme Labs organization switcher"]');
|
|
expect(trigger).not.toBeNull();
|
|
|
|
act(() => {
|
|
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
const editButton = Array.from(document.body.querySelectorAll("button"))
|
|
.find((element) => element.textContent === "Edit");
|
|
expect(editButton).toBeTruthy();
|
|
|
|
act(() => {
|
|
editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(document.body.textContent).toContain("Done");
|
|
expect(document.body.textContent).not.toContain("PAP");
|
|
expect(document.body.textContent).not.toContain("ANA");
|
|
const reorderButton = document.body.querySelector<HTMLButtonElement>(
|
|
'button[aria-label="Reorder Strata"]',
|
|
);
|
|
expect(reorderButton).toBeTruthy();
|
|
expect(reorderButton?.classList).toContain("size-8");
|
|
|
|
const strataItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Strata"));
|
|
expect(strataItem).toBeTruthy();
|
|
|
|
act(() => {
|
|
strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockSetSelectedCompanyId).not.toHaveBeenCalled();
|
|
expect(mockNavigate).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("navigates to the selected company dashboard from company-prefixed routes", async () => {
|
|
mockLocation.pathname = "/PAP/issues";
|
|
const root = createRoot(container);
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
|
|
act(() => {
|
|
root.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<SidebarCompanyMenu />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
const trigger = container.querySelector('button[aria-label="Open Acme Labs organization switcher"]');
|
|
expect(trigger).not.toBeNull();
|
|
|
|
act(() => {
|
|
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
|
|
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
const strataItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Strata"));
|
|
expect(strataItem).toBeTruthy();
|
|
|
|
act(() => {
|
|
strataItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockSetSelectedCompanyId).toHaveBeenCalledWith("company-2");
|
|
expect(mockNavigate).toHaveBeenCalledWith("/STR/dashboard");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("keeps the in-app company wizard and never leaves the app when self-hosted", async () => {
|
|
const { root } = renderMenu();
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Create organization"));
|
|
expect(createItem).toBeTruthy();
|
|
|
|
act(() => {
|
|
createItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockOpenOnboarding).toHaveBeenCalledTimes(1);
|
|
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
|
|
expect(mockCloudApi.listStacks).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
// The trigger sits in a fixed 240px sidebar header beside two shrink-0
|
|
// controls, so a long name may only truncate — never widen the row and push
|
|
// the chevron, search, and collapse controls out of the panel. Truncation
|
|
// needs `min-w-0` on EVERY flex link of the chain (a flex item's default
|
|
// `min-width:auto` floors it at its content width), which is invisible to
|
|
// behavioural assertions, so the class chain itself is the contract.
|
|
it("lets a long workspace name truncate instead of widening the trigger", async () => {
|
|
const { root } = renderMenu();
|
|
await flushReact();
|
|
|
|
const trigger = container.querySelector('button[aria-label="Open Acme Labs organization switcher"]');
|
|
expect(trigger).not.toBeNull();
|
|
expect(trigger?.className).toContain("min-w-0");
|
|
|
|
const labelRow = trigger?.firstElementChild;
|
|
expect(labelRow?.className).toContain("min-w-0");
|
|
|
|
const label = labelRow?.lastElementChild;
|
|
expect(label?.textContent).toBe("Acme Labs");
|
|
expect(label?.className).toContain("min-w-0");
|
|
expect(label?.className).toContain("truncate");
|
|
// A truncated name stays recoverable on hover.
|
|
expect(label?.getAttribute("title")).toBe("Acme Labs");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
describe("in Paperclip Cloud", () => {
|
|
it("closes the menu and enters the Cloud logout flow without local sign-out", async () => {
|
|
const { root } = renderMenu({ cloud: true });
|
|
await flushReact();
|
|
await flushReact();
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
const signOutItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Sign out"));
|
|
expect(signOutItem).toBeTruthy();
|
|
|
|
act(() => {
|
|
signOutItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockAuthApi.signOut).not.toHaveBeenCalled();
|
|
expect(mockNavigateTopLevel).toHaveBeenCalledOnce();
|
|
expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout");
|
|
expect(document.body.textContent).not.toContain("Organizations");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("switches organizations instead of companies", async () => {
|
|
const { root } = renderMenu({ cloud: true });
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
expect(mockCloudApi.listStacks).toHaveBeenCalledTimes(1);
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("Organizations");
|
|
expect(document.body.textContent).toContain("Create organization");
|
|
expect(document.body.textContent).not.toContain("Organization settings");
|
|
expect(document.body.textContent).not.toContain("Switch company");
|
|
expect(document.body.textContent).not.toContain("Create new company...");
|
|
expect(document.body.textContent).not.toContain("Company settings");
|
|
|
|
// Stacks, not companies: both rows carry the slug badge, and the current
|
|
// stack is the checked one.
|
|
expect(document.body.textContent).toContain("Acme Labs");
|
|
expect(document.body.textContent).toContain("acme-labs");
|
|
expect(document.body.textContent).toContain("Strata Systems");
|
|
expect(document.body.textContent).toContain("strata");
|
|
expect(document.body.textContent).not.toContain("Anachronist Wiki");
|
|
expect(document.body.textContent).not.toContain("ANA");
|
|
|
|
const currentRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Acme Labs"));
|
|
expect(currentRow?.classList.contains("bg-accent")).toBe(false);
|
|
|
|
// Long slugs must not squeeze the display name out of the row, so the
|
|
// secondary line truncates and keeps the full slug on hover.
|
|
const slugBadge = document.body.querySelector('[title="strata"]');
|
|
expect(slugBadge?.className).toContain("truncate");
|
|
|
|
// Drag-to-reorder is self-hosted only in v1.
|
|
expect(Array.from(document.body.querySelectorAll("button"))
|
|
.some((element) => element.textContent === "Edit")).toBe(false);
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("shows the synced company logo on the trigger while stack rows keep monograms", async () => {
|
|
const { root } = renderMenu({ cloud: true });
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
// A Cloud tenant holds exactly one company, and the harness pushes the
|
|
// stack's uploaded workspace icon into that company's branding — so the
|
|
// trigger renders the company logo, not the monogram.
|
|
const trigger = container.querySelector(
|
|
'button[aria-label="Open Acme Labs organization switcher"]',
|
|
);
|
|
expect(trigger).not.toBeNull();
|
|
expect(
|
|
trigger?.querySelector('[data-logo-url="/api/assets/logo-asset-1/content"]'),
|
|
).not.toBeNull();
|
|
|
|
// Other stacks have no hot-linkable icon in the portfolio payload, so
|
|
// their rows keep the deterministic monogram treatment.
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
const strataRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Strata Systems"));
|
|
expect(strataRow).toBeTruthy();
|
|
expect(strataRow?.querySelector("[data-logo-url]")).toBeNull();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("enters another stack through a full top-level navigation", async () => {
|
|
const { root } = renderMenu({ cloud: true });
|
|
await flushReact();
|
|
await flushReact();
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
const strataRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Strata Systems"));
|
|
expect(strataRow).toBeTruthy();
|
|
|
|
act(() => {
|
|
strataRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockNavigateTopLevel).toHaveBeenCalledWith(
|
|
"https://cloud.example.test/stacks/strata/enter",
|
|
);
|
|
// No in-app company selection and no client-side route change.
|
|
expect(mockSetSelectedCompanyId).not.toHaveBeenCalled();
|
|
expect(mockNavigate).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("does not navigate when the current stack is picked again", async () => {
|
|
const { root } = renderMenu({ cloud: true });
|
|
await flushReact();
|
|
await flushReact();
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
const currentRow = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Acme Labs"));
|
|
|
|
act(() => {
|
|
currentRow?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("sends organization creation to the cloud app, not the in-app wizard", async () => {
|
|
const { root } = renderMenu({ cloud: true });
|
|
await flushReact();
|
|
await flushReact();
|
|
await openMenu("Open Acme Labs organization switcher");
|
|
|
|
const createItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
|
.find((element) => element.textContent?.includes("Create organization"));
|
|
expect(createItem).toBeTruthy();
|
|
|
|
act(() => {
|
|
createItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
});
|
|
await flushReact();
|
|
|
|
expect(mockNavigateTopLevel).toHaveBeenCalledWith("https://cloud.example.test/stacks/new");
|
|
expect(mockOpenOnboarding).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
|
|
it("falls back to the stack slug and hides creation without a cloud base url", async () => {
|
|
mockCloudApi.listStacks.mockRejectedValue(new Error("portfolio unavailable"));
|
|
const { root } = renderMenu({
|
|
cloud: true,
|
|
health: {
|
|
status: "ok" as const,
|
|
cloud: { ...CLOUD_HEALTH.cloud, cloudBaseUrl: null },
|
|
},
|
|
});
|
|
await flushReact();
|
|
await flushReact();
|
|
|
|
await openMenu("Open acme-labs organization switcher");
|
|
|
|
expect(document.body.textContent).toContain("Could not load organizations");
|
|
expect(document.body.textContent).not.toContain("Create organization");
|
|
|
|
act(() => {
|
|
root.unmount();
|
|
});
|
|
});
|
|
});
|
|
});
|