Files
PaperClipAI/ui/src/pages/CompanySettingsArchive.test.tsx
T
Devin Foley 64faf0ae90 fix(ui): leave the archived company's settings after archiving it (#13846)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - A person can archive a company from that company's settings page.
> - After the archive, the page stays on the archived company. The only
visible change is the button text "Already archived".
> - It is unclear that anything happened, and the user has no next step
on screen.
> - This pull request navigates away after a successful archive: to
another active company, to the Cloud portfolio when no active company
remains on a managed instance, or to the companies list when
self-hosted.
> - The benefit is a clear outcome: the user sees where they are now and
a toast that names what happened.

## Linked Issues or Issue Description

No public issue exists. Description follows the enhancement template:

**What existing behavior does this improve?**

The archive action on the company settings page. The mutation works, but
the view stays on the archived company and gives no feedback beyond a
disabled button.

**Subsystem affected**

UI: company settings page, company selection helpers, cloud links.

**Current behavior**

Archive succeeds. The button changes to "Already archived". The user
stays on the archived company's settings. On a single-company instance
nothing else changes.

**Proposed behavior**

After a successful archive, the app departs: another active company's
dashboard with a toast; the Cloud portfolio's manage view when no active
company remains on a managed instance; the companies list when
self-hosted with nothing active, because that list shows the archived
state and owns the unarchive action.

**Reason and benefit**

Staying on the archived company reads as "nothing happened". Leaving to
a live surface makes the outcome clear and gives the user their next
step.

## What Changed

- `ui/src/lib/company-selection.ts`: new pure
`resolveCompanyArchiveDeparture` helper. Another active company wins;
the just-archived company is excluded by id, so a stale cached status
cannot select it. Cloud portfolio is the fallback on managed instances;
the companies list is the final fallback.
- `ui/src/lib/cloudLinks.ts`: new `cloudPortfolioManageUrl`
(`/orgs?manage=1`). The manage view matters: the plain launchpad
auto-forwards a solo user back into their one openable stack — the page
this navigation is escaping.
- `ui/src/pages/CompanySettings.tsx`: the archive mutation now departs
on success — toast + `navigate` for in-app destinations, a top-level
navigation for the Cloud portfolio — instead of only setting the
selected company id.
- `ui/src/pages/CompanySettingsRenameHint.test.tsx`: render harness
wraps the page in a `MemoryRouter` for the new router dependency.

## Verification

- `pnpm exec vitest run src/lib/company-selection.test.ts
src/lib/cloudLinks.test.ts src/pages/CompanySettingsRenameHint.test.tsx
src/pages/CompanySettings.test.tsx` — 22 tests pass.
- New unit tests cover: active sibling wins (also on cloud), the
just-archived company is never the destination even with a stale cached
status, cloud portfolio fallback, self-hosted companies-list fallback,
and the portfolio URL helper (null base included).
- `pnpm exec tsc --noEmit` in `ui/` is clean after the plugin SDK build.

## Risks

- Low risk. The archive API call is unchanged; only post-success
navigation is new. The toast uses the optional actions hook, so surfaces
without a ToastProvider stay safe.
- On Cloud, the portfolio navigation is a full top-level navigation, so
the query-cache invalidation for the departed document is skipped
intentionally.

## Model Used

- Claude Fable 5 (`claude-fable-5`), via Claude Code CLI, extended
thinking and tool use enabled.

## 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
2026-09-22 20:36:20 -07:00

234 lines
6.9 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { MemoryRouter, useLocation } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { queryKeys } from "@/lib/queryKeys";
import { CompanySettings } from "./CompanySettings";
const mockCompaniesApi = vi.hoisted(() => ({
update: vi.fn(),
archive: vi.fn(),
}));
const mockAssetsApi = vi.hoisted(() => ({
uploadCompanyLogo: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockPushToast = vi.hoisted(() => vi.fn());
const mockNavigateTopLevel = vi.hoisted(() => vi.fn());
const ARCHIVING_COMPANY = {
id: "company-old",
name: "Old Co",
description: null,
status: "active",
issuePrefix: "OLD",
brandColor: null,
logoUrl: null,
attachmentMaxBytes: null,
requireBoardApprovalForNewAgents: false,
interactionResolverGovernance: {},
};
const SIBLING_COMPANY = {
...ARCHIVING_COMPANY,
id: "company-pap",
name: "Paperclip",
issuePrefix: "PAP",
};
// Mutable so each test shapes the portfolio the departure resolves against.
const companyState = vi.hoisted(() => ({
companies: [] as unknown[],
}));
vi.mock("../api/companies", () => ({ companiesApi: mockCompaniesApi }));
vi.mock("../api/assets", () => ({ assetsApi: mockAssetsApi }));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
}));
vi.mock("../context/ToastContext", () => ({
useOptionalToastActions: () => ({ pushToast: mockPushToast }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: companyState.companies,
selectedCompany: ARCHIVING_COMPANY,
selectedCompanyId: ARCHIVING_COMPANY.id,
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
}));
vi.mock("@/lib/browserNavigation", () => ({
navigateTopLevel: mockNavigateTopLevel,
}));
// Both panels below the danger zone own their own queries and are not part
// of what this test covers.
vi.mock("../components/InteractionGovernancePanel", () => ({
InteractionGovernancePanel: () => null,
applyGovernanceChange: (governance: unknown) => governance,
}));
vi.mock("./InstanceGeneralSettings", () => ({
InstanceGeneralSettings: () => null,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
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 SELF_HOSTED_HEALTH = { status: "ok" as const, cloud: null };
let observedPath = "";
function LocationProbe() {
const location = useLocation();
observedPath = `${location.pathname}${location.search}`;
return null;
}
async function flushReact() {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
async function waitForAssertion(assertion: () => void) {
let lastError: unknown;
for (let i = 0; i < 20; i += 1) {
await flushReact();
try {
assertion();
return;
} catch (error) {
lastError = error;
}
}
throw lastError;
}
describe("CompanySettings archive departure", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockCompaniesApi.archive.mockResolvedValue({ id: ARCHIVING_COMPANY.id, status: "archived" });
vi.spyOn(window, "confirm").mockReturnValue(true);
observedPath = "";
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.restoreAllMocks();
vi.clearAllMocks();
});
function render(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 managed instance under test.
queryClient.setQueryData(queryKeys.health, health);
const root = createRoot(container);
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<MemoryRouter initialEntries={["/OLD/company/settings"]}>
<LocationProbe />
<CompanySettings />
</MemoryRouter>
</TooltipProvider>
</QueryClientProvider>,
);
});
return root;
}
function clickArchive() {
const button = Array.from(container.querySelectorAll("button")).find(
(element) => element.textContent?.trim() === "Archive organization",
);
expect(button).toBeDefined();
flushSync(() => {
button!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}
it("switches to another active company with a toast", async () => {
companyState.companies = [ARCHIVING_COMPANY, SIBLING_COMPANY];
const root = render(SELF_HOSTED_HEALTH);
clickArchive();
await waitForAssertion(() => {
expect(mockCompaniesApi.archive).toHaveBeenCalledWith(ARCHIVING_COMPANY.id);
expect(mockSetSelectedCompanyId).toHaveBeenCalledWith(SIBLING_COMPANY.id);
expect(observedPath).toBe("/PAP/dashboard");
expect(mockPushToast).toHaveBeenCalledWith(
expect.objectContaining({
title: "Old Co is archived",
body: "Switched to Paperclip.",
}),
);
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
});
flushSync(() => root.unmount());
});
it("leaves for the Cloud portfolio's manage view when no active company remains", async () => {
companyState.companies = [ARCHIVING_COMPANY];
const root = render(CLOUD_HEALTH);
clickArchive();
await waitForAssertion(() => {
expect(mockNavigateTopLevel).toHaveBeenCalledWith(
"https://cloud.example.test/orgs?manage=1",
);
// A full navigation replaces the document: no in-app route change and
// no company switch.
expect(observedPath).toBe("/OLD/company/settings");
expect(mockSetSelectedCompanyId).not.toHaveBeenCalled();
});
flushSync(() => root.unmount());
});
it("falls back to the companies list on self-hosted with nothing active", async () => {
companyState.companies = [ARCHIVING_COMPANY];
const root = render(SELF_HOSTED_HEALTH);
clickArchive();
await waitForAssertion(() => {
expect(observedPath).toBe("/OLD/companies");
expect(mockPushToast).toHaveBeenCalledWith(
expect.objectContaining({
title: "Old Co is archived",
body: "You can unarchive it from this list.",
}),
);
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
});
flushSync(() => root.unmount());
});
});