mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 07:10:34 +01:00
## 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
234 lines
6.9 KiB
TypeScript
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());
|
|
});
|
|
});
|