mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
[codex] Bundle local branch fixes from PAP-10032 (#6604)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - This branch accumulated multiple already-tested control-plane, adapter runtime, invite, workspace, plugin, and UI quality fixes on the primary Paperclip checkout. > - `origin/master` advanced while those commits were still local, so the branch needed to be preserved and reconciled before review. > - Splitting the branch commit-by-commit against the new base produced overlapping conflicts with recently merged upstream PRs. > - This pull request keeps the remaining branch as one standalone PR because the final diff is 38 files after removing screenshot artifacts, under Greptile's 100-file cap, and can be merged independently after review. > - The benefit is that none of the local work is lost, the branch is now based on current `origin/master`, and reviewers can evaluate the reconciled changes in one place. ## What Changed - Merged the local accumulated branch with current `origin/master` and resolved the invite-flow overlaps from the newer upstream companies query helper. - Preserved the local fixes for invite existing-member behavior, invite link copy fallback, reusable workspace selection, worktree auth, static SPA fallback, markdown wrapping, plugin slot registration, cloud upstream UX/server polish, project sorting, and related tests. - Removed screenshot artifacts from the PR per review request. - Kept the PR under the requested file limit: 38 files changed, with no `pnpm-lock.yaml` or `.github/workflows/*` changes. ## Verification - `NODE_ENV=test pnpm exec vitest run ui/src/pages/CompanyInvites.test.tsx ui/src/pages/InviteLanding.test.tsx ui/src/pages/Projects.test.tsx ui/src/plugins/slots.test.ts ui/src/components/MarkdownBody.test.tsx server/src/__tests__/invite-accept-existing-member.test.ts server/src/__tests__/static-index-html.test.ts server/src/__tests__/execution-workspaces-service.test.ts server/src/__tests__/better-auth.test.ts server/src/__tests__/worktree-config.test.ts` - `NODE_ENV=test pnpm --filter @paperclipai/ui typecheck` - `NODE_ENV=test pnpm --filter @paperclipai/server typecheck` - Confirmed `git diff --name-only origin/master...HEAD | wc -l` is `38`. - Confirmed no PR diff entries match `pnpm-lock.yaml`, `.github/workflows/*`, or `screenshots/*`. ## Risks - Medium review risk because this is a bundled rescue PR rather than several narrow feature PRs. - Invite flow and company cache behavior overlapped with newer upstream changes; the merge resolution intentionally keeps the shared `companiesListQueryOptions` helper while preserving local existing-member invite behavior. - Visual review evidence is no longer attached in-repo because screenshots were removed from this PR per review request. > 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 - OpenAI Codex, GPT-5-based coding agent, with repository tool access, terminal execution, and git/GitHub CLI operations. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] UI screenshots were intentionally removed from this PR per review request - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: CodexCoder <codexcoder@paperclip.local>
This commit is contained in:
parent
96f0279e08
commit
ece8a51e22
38 changed files with 1715 additions and 134 deletions
|
|
@ -1,6 +1,5 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { CloudUpstreamRun, CloudUpstreamsState } from "@paperclipai/shared";
|
||||
|
|
@ -65,6 +64,12 @@ vi.mock("@/lib/router", () => ({
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
await callback();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ export function CompanyAccess() {
|
|||
<div>
|
||||
<h3 className="text-sm font-semibold">Pending human joins</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Review human join requests before they become active company members.
|
||||
Review pending join requests before they become active company members.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{pendingHumanJoinRequests.length} pending</Badge>
|
||||
|
|
|
|||
|
|
@ -203,7 +203,11 @@ describe("CompanyInvites", () => {
|
|||
expect(clipboardWriteTextMock).toHaveBeenCalledWith("https://paperclip.local/invite/new-token");
|
||||
expect(container.textContent).toContain("Latest invite link");
|
||||
expect(container.textContent).toContain("This URL includes the current Paperclip domain returned by the server.");
|
||||
expect(container.textContent).toContain("https://paperclip.local/invite/new-token");
|
||||
expect(container.querySelector('input[aria-label="Latest invite URL"]')).toHaveProperty(
|
||||
"value",
|
||||
"https://paperclip.local/invite/new-token",
|
||||
);
|
||||
expect(container.textContent).toContain("Copy link");
|
||||
expect(container.textContent).toContain("Open invite");
|
||||
expect(pushToastMock).toHaveBeenCalledWith({
|
||||
title: "Invite created",
|
||||
|
|
@ -211,12 +215,12 @@ describe("CompanyInvites", () => {
|
|||
tone: "success",
|
||||
});
|
||||
|
||||
const inviteFieldButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("https://paperclip.local/invite/new-token"),
|
||||
const copyLinkButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Copy link",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
inviteFieldButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
copyLinkButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
|
|
@ -235,6 +239,61 @@ describe("CompanyInvites", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("falls back to selectable text when browser clipboard access is unavailable", async () => {
|
||||
Object.defineProperty(globalThis.navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
Object.defineProperty(document, "queryCommandSupported", {
|
||||
configurable: true,
|
||||
value: vi.fn((command: string) => command === "copy"),
|
||||
});
|
||||
Object.defineProperty(document, "execCommand", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => true),
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanyInvites />
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const createButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Create invite",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const inviteInput = container.querySelector('input[aria-label="Latest invite URL"]') as HTMLInputElement | null;
|
||||
expect(inviteInput?.value).toBe("https://paperclip.local/invite/new-token");
|
||||
expect(document.execCommand).toHaveBeenCalledWith("copy");
|
||||
expect(pushToastMock).toHaveBeenCalledWith({
|
||||
title: "Invite created",
|
||||
body: "Invite ready below and copied to clipboard.",
|
||||
tone: "success",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores legacy cached invite arrays and refetches paginated history", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, MailPlus } from "lucide-react";
|
||||
import { Check, Copy, ExternalLink, MailPlus } from "lucide-react";
|
||||
import { accessApi } from "@/api/access";
|
||||
import { ApiError } from "@/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -52,6 +52,7 @@ export function CompanyInvites() {
|
|||
const [humanRole, setHumanRole] = useState<"owner" | "admin" | "operator" | "viewer">("operator");
|
||||
const [latestInviteUrl, setLatestInviteUrl] = useState<string | null>(null);
|
||||
const [latestInviteCopied, setLatestInviteCopied] = useState(false);
|
||||
const latestInviteInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latestInviteCopied) return;
|
||||
|
|
@ -61,7 +62,12 @@ export function CompanyInvites() {
|
|||
return () => window.clearTimeout(timeout);
|
||||
}, [latestInviteCopied]);
|
||||
|
||||
async function copyText(text: string, unavailableBody: string) {
|
||||
function selectLatestInviteUrl() {
|
||||
latestInviteInputRef.current?.focus();
|
||||
latestInviteInputRef.current?.select();
|
||||
}
|
||||
|
||||
async function copyText(text: string, unavailableBody: string, afterFallback?: () => void) {
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
|
|
@ -71,6 +77,33 @@ export function CompanyInvites() {
|
|||
// Fall through to the unavailable message below.
|
||||
}
|
||||
|
||||
const canUseLegacyCopy =
|
||||
typeof document !== "undefined" &&
|
||||
typeof document.execCommand === "function" &&
|
||||
(typeof document.queryCommandSupported !== "function" || document.queryCommandSupported("copy"));
|
||||
if (canUseLegacyCopy) {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "true");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
try {
|
||||
const copied = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
afterFallback?.();
|
||||
if (copied) return true;
|
||||
} catch {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
afterFallback?.();
|
||||
pushToast({
|
||||
title: "Clipboard unavailable",
|
||||
body: unavailableBody,
|
||||
|
|
@ -79,6 +112,10 @@ export function CompanyInvites() {
|
|||
return false;
|
||||
}
|
||||
|
||||
async function copyInviteUrl(url: string) {
|
||||
return copyText(url, "The invite URL is selected. Copy it manually from the field.", selectLatestInviteUrl);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
||||
|
|
@ -225,7 +262,7 @@ export function CompanyInvites() {
|
|||
</fieldset>
|
||||
|
||||
<div className="rounded-lg border border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.
|
||||
Each invite link is single-use. Human invitees get the selected role immediately after sign-in; agent invites still create a join request for approval.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
|
@ -251,17 +288,31 @@ export function CompanyInvites() {
|
|||
This URL includes the current Paperclip domain returned by the server.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const copied = await copyText(latestInviteUrl, "Copy the invite URL manually from the field below.");
|
||||
setLatestInviteCopied(copied);
|
||||
}}
|
||||
className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-left text-sm break-all transition-colors hover:bg-background"
|
||||
>
|
||||
{latestInviteUrl}
|
||||
</button>
|
||||
<label className="block space-y-1">
|
||||
<span className="sr-only">Latest invite URL</span>
|
||||
<input
|
||||
ref={latestInviteInputRef}
|
||||
readOnly
|
||||
value={latestInviteUrl}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-foreground outline-none transition-colors selection:bg-primary selection:text-primary-foreground focus:border-ring"
|
||||
aria-label="Latest invite URL"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
const copied = await copyInviteUrl(latestInviteUrl);
|
||||
setLatestInviteCopied(copied);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy link
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={latestInviteUrl} target="_blank" rel="noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { InviteLandingPage } from "./InviteLanding";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
||||
const getInviteMock = vi.hoisted(() => vi.fn());
|
||||
const acceptInviteMock = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -216,6 +217,11 @@ describe("InviteLandingPage", () => {
|
|||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.access.currentBoardAccess, {
|
||||
userId: "user-1",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
|
|
@ -302,6 +308,11 @@ describe("InviteLandingPage", () => {
|
|||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.access.currentBoardAccess, {
|
||||
userId: "user-1",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
|
|
@ -354,6 +365,11 @@ describe("InviteLandingPage", () => {
|
|||
});
|
||||
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
|
||||
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
|
||||
expect(queryClient.getQueryState(queryKeys.access.currentBoardAccess)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryData(queryKeys.companies.all)).toMatchObject({
|
||||
companies: [],
|
||||
unauthorized: false,
|
||||
});
|
||||
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -422,7 +438,7 @@ describe("InviteLandingPage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("keeps the waiting-for-approval state on refresh for an accepted invite", async () => {
|
||||
it("auto-completes a previously accepted human invite after sign-in", async () => {
|
||||
getInviteMock.mockResolvedValue({
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
|
|
@ -437,6 +453,12 @@ describe("InviteLandingPage", () => {
|
|||
joinRequestStatus: "pending_approval",
|
||||
joinRequestType: "human",
|
||||
});
|
||||
acceptInviteMock.mockResolvedValue({
|
||||
id: "join-1",
|
||||
companyId: "company-1",
|
||||
requestType: "human",
|
||||
status: "approved",
|
||||
});
|
||||
getSessionMock.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
|
|
@ -447,6 +469,58 @@ describe("InviteLandingPage", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.access.currentBoardAccess, {
|
||||
userId: "user-1",
|
||||
isInstanceAdmin: false,
|
||||
companyIds: [],
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
|
||||
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
|
||||
expect(queryClient.getQueryState(queryKeys.access.currentBoardAccess)?.isInvalidated).toBe(true);
|
||||
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("asks unauthenticated users to sign in before completing an accepted human invite", async () => {
|
||||
getInviteMock.mockResolvedValue({
|
||||
id: "invite-1",
|
||||
companyId: "company-1",
|
||||
companyName: "Acme Robotics",
|
||||
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
|
||||
companyBrandColor: "#114488",
|
||||
inviteType: "company_join",
|
||||
allowedJoinTypes: "human",
|
||||
humanRole: "operator",
|
||||
expiresAt: "2027-03-07T00:10:00.000Z",
|
||||
inviteMessage: "Welcome aboard.",
|
||||
joinRequestStatus: "pending_approval",
|
||||
joinRequestType: "human",
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
|
|
@ -465,14 +539,11 @@ describe("InviteLandingPage", () => {
|
|||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(acceptInviteMock).not.toHaveBeenCalled();
|
||||
expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Your request is still awaiting approval.");
|
||||
expect(container.textContent).toContain(
|
||||
"Ask them to visit Company Settings → Members to approve your request.",
|
||||
);
|
||||
expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
|
||||
expect(container.textContent).toContain("Create your account");
|
||||
expect(container.querySelector('[data-testid="invite-pending-approval"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -551,6 +622,10 @@ describe("InviteLandingPage", () => {
|
|||
});
|
||||
expect(acceptInviteMock).not.toHaveBeenCalled();
|
||||
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
|
||||
expect(queryClient.getQueryData(queryKeys.companies.all)).toMatchObject({
|
||||
companies: [{ id: "company-1", name: "Acme Robotics" }],
|
||||
unauthorized: false,
|
||||
});
|
||||
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -558,6 +633,59 @@ describe("InviteLandingPage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("shows invite details instead of auto-redirecting for signed-in existing members", async () => {
|
||||
getSessionMock.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
listCompaniesMock.mockResolvedValue([{ id: "company-1", name: "Acme Robotics" }]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Join Acme Robotics");
|
||||
expect(container.textContent).toContain("Already in this company");
|
||||
expect(container.textContent).toContain("This account already belongs to Acme Robotics.");
|
||||
expect(acceptInviteMock).not.toHaveBeenCalled();
|
||||
|
||||
const openButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Open company",
|
||||
);
|
||||
expect(openButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
openButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the generated company icon when the invite logo fails to load", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
|
|
@ -594,6 +722,55 @@ describe("InviteLandingPage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("normalizes the shared company cache envelope before checking membership", async () => {
|
||||
acceptInviteMock.mockResolvedValue({
|
||||
id: "join-1",
|
||||
companyId: "company-1",
|
||||
requestType: "human",
|
||||
status: "pending_approval",
|
||||
});
|
||||
getSessionMock.mockResolvedValue({
|
||||
session: { id: "session-1", userId: "user-1" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
name: "Jane Example",
|
||||
email: "jane@example.com",
|
||||
image: null,
|
||||
},
|
||||
});
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
queryClient.setQueryData(queryKeys.companies.all, {
|
||||
companies: [],
|
||||
unauthorized: false,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="/invite/:token" element={<InviteLandingPage />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
|
||||
expect(container.textContent).toContain("Request to join Acme Robotics");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the membership check before showing invite acceptance to signed-in users", async () => {
|
||||
let resolveCompanies: ((value: Array<{ id: string; name: string }>) => void) | null = null;
|
||||
acceptInviteMock.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -266,9 +266,8 @@ export function InviteLandingPage() {
|
|||
if (!list || !inviteQuery.data?.companyId) return;
|
||||
if (list.some((c) => c.id === inviteQuery.data!.companyId)) {
|
||||
clearPendingInviteToken(token);
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
}, [companiesQuery.data, inviteQuery.data, token, navigate]);
|
||||
}, [companiesQuery.data, inviteQuery.data, token]);
|
||||
|
||||
const invite = inviteQuery.data;
|
||||
const isCheckingExistingMembership =
|
||||
|
|
@ -287,6 +286,9 @@ export function InviteLandingPage() {
|
|||
const requestedHumanRole = formatHumanRole(invite?.humanRole);
|
||||
const inviteJoinRequestStatus = invite?.joinRequestStatus ?? null;
|
||||
const inviteJoinRequestType = invite?.joinRequestType ?? null;
|
||||
const canCompleteAcceptedHumanInvite =
|
||||
inviteJoinRequestType === "human" &&
|
||||
(inviteJoinRequestStatus === "pending_approval" || inviteJoinRequestStatus === "approved");
|
||||
const requiresHumanAccount =
|
||||
healthQuery.data?.deploymentMode === "authenticated" &&
|
||||
!sessionQuery.data &&
|
||||
|
|
@ -296,7 +298,7 @@ export function InviteLandingPage() {
|
|||
Boolean(sessionQuery.data) &&
|
||||
!showsAgentForm &&
|
||||
invite?.inviteType !== "bootstrap_ceo" &&
|
||||
!inviteJoinRequestStatus &&
|
||||
(!inviteJoinRequestStatus || canCompleteAcceptedHumanInvite) &&
|
||||
!isCheckingExistingMembership &&
|
||||
!isCurrentMember &&
|
||||
!result &&
|
||||
|
|
@ -336,6 +338,7 @@ export function InviteLandingPage() {
|
|||
const asBootstrap = isBootstrapAcceptancePayload(payload);
|
||||
setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
||||
if (invite?.companyId && isApprovedHumanJoinPayload(payload, showsAgentForm)) {
|
||||
setSelectedCompanyId(invite.companyId, { source: "manual" });
|
||||
|
|
@ -370,6 +373,7 @@ export function InviteLandingPage() {
|
|||
setAuthFeedback(null);
|
||||
rememberPendingInviteToken(token);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess });
|
||||
const { companies: freshCompanies } = await queryClient.fetchQuery(companiesListQueryOptions);
|
||||
|
||||
if (invite?.companyId && freshCompanies.some((company) => company.id === invite.companyId)) {
|
||||
|
|
@ -404,10 +408,11 @@ export function InviteLandingPage() {
|
|||
|
||||
const joinButtonLabel = useMemo(() => {
|
||||
if (!invite) return "Continue";
|
||||
if (isCurrentMember) return "Open company";
|
||||
if (invite.inviteType === "bootstrap_ceo") return "Accept invite";
|
||||
if (showsAgentForm) return "Submit request";
|
||||
return sessionQuery.data ? "Accept invite" : "Continue";
|
||||
}, [invite, sessionQuery.data, showsAgentForm]);
|
||||
}, [invite, isCurrentMember, sessionQuery.data, showsAgentForm]);
|
||||
|
||||
if (!token) {
|
||||
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid invite token.</div>;
|
||||
|
|
@ -442,7 +447,7 @@ export function InviteLandingPage() {
|
|||
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Opening company...</div>;
|
||||
}
|
||||
|
||||
if (inviteJoinRequestStatus === "pending_approval") {
|
||||
if (inviteJoinRequestStatus === "pending_approval" && !canCompleteAcceptedHumanInvite) {
|
||||
return (
|
||||
<AwaitingJoinApprovalPanel
|
||||
companyDisplayName={companyDisplayName}
|
||||
|
|
@ -453,7 +458,7 @@ export function InviteLandingPage() {
|
|||
);
|
||||
}
|
||||
|
||||
if (inviteJoinRequestStatus) {
|
||||
if (inviteJoinRequestStatus && !canCompleteAcceptedHumanInvite) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="border border-border bg-card p-6" data-testid="invite-error">
|
||||
|
|
@ -778,19 +783,21 @@ export function InviteLandingPage() {
|
|||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{shouldAutoAcceptHumanInvite
|
||||
? "Submitting join request"
|
||||
{isCurrentMember
|
||||
? "Already in this company"
|
||||
: shouldAutoAcceptHumanInvite
|
||||
? "Completing company access"
|
||||
: invite.inviteType === "bootstrap_ceo"
|
||||
? "Accept bootstrap invite"
|
||||
: "Accept company invite"}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{shouldAutoAcceptHumanInvite
|
||||
? `Submitting your join request for ${companyDisplayName}.`
|
||||
? `Granting your access to ${companyDisplayName}.`
|
||||
: isCurrentMember
|
||||
? `This account already belongs to ${companyDisplayName}.`
|
||||
: `This will ${
|
||||
invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `submit or complete your join request for ${companyDisplayName}`
|
||||
invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `grant or complete your access to ${companyDisplayName}`
|
||||
}.`}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -802,8 +809,16 @@ export function InviteLandingPage() {
|
|||
) : (
|
||||
<Button
|
||||
className="w-full rounded-none"
|
||||
disabled={acceptMutation.isPending || isCurrentMember}
|
||||
onClick={() => acceptMutation.mutate()}
|
||||
disabled={acceptMutation.isPending}
|
||||
onClick={() => {
|
||||
if (isCurrentMember && invite.companyId) {
|
||||
clearPendingInviteToken(token);
|
||||
setSelectedCompanyId(invite.companyId, { source: "manual" });
|
||||
navigate("/", { replace: true });
|
||||
return;
|
||||
}
|
||||
acceptMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -371,10 +371,10 @@ function AcceptInvitePreview({
|
|||
<h3 className="text-lg font-semibold text-zinc-100">Accept company invite</h3>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
{autoAccept
|
||||
? "Submitting your join request for Acme Robotics."
|
||||
? "Granting your access to Acme Robotics."
|
||||
: isCurrentMember
|
||||
? "This account already belongs to Acme Robotics."
|
||||
: "This will submit or complete your join request for Acme Robotics."}
|
||||
: "This will grant or complete your access to Acme Robotics."}
|
||||
</p>
|
||||
</div>
|
||||
{error ? <p className="text-xs text-red-400">{error}</p> : null}
|
||||
|
|
@ -572,7 +572,7 @@ function CompanyInvitesPreview() {
|
|||
</fieldset>
|
||||
|
||||
<div className="rounded-xl border border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.
|
||||
Each invite link is single-use. Human invitees get the selected role immediately after sign-in; agent invites still create a join request for approval.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
|
|
|
|||
164
ui/src/pages/Projects.test.tsx
Normal file
164
ui/src/pages/Projects.test.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Projects } from "./Projects";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const mockProjectsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
}));
|
||||
const mockOpenNewProject = vi.hoisted(() => vi.fn());
|
||||
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
}));
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
||||
}));
|
||||
vi.mock("../context/DialogContext", () => ({
|
||||
useDialogActions: () => ({ openNewProject: mockOpenNewProject }),
|
||||
}));
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
|
||||
}));
|
||||
|
||||
function project(overrides: Partial<Project>): Project {
|
||||
const now = new Date("2026-05-01T00:00:00Z");
|
||||
return {
|
||||
id: "project-1",
|
||||
companyId: "company-1",
|
||||
urlKey: "project-1",
|
||||
goalId: null,
|
||||
goalIds: [],
|
||||
goals: [],
|
||||
name: "Project",
|
||||
description: null,
|
||||
status: "in_progress",
|
||||
leadAgentId: null,
|
||||
targetDate: null,
|
||||
color: "#14b8a6",
|
||||
env: null,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
executionWorkspacePolicy: null,
|
||||
codebase: {
|
||||
workspaceId: null,
|
||||
repoUrl: null,
|
||||
repoRef: null,
|
||||
defaultRef: null,
|
||||
repoName: null,
|
||||
localFolder: null,
|
||||
managedFolder: "/tmp/project-1",
|
||||
effectiveLocalFolder: "/tmp/project-1",
|
||||
origin: "managed_checkout",
|
||||
},
|
||||
workspaces: [],
|
||||
primaryWorkspace: null,
|
||||
archivedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderProjects(container: HTMLElement) {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
let root: Root | null = null;
|
||||
|
||||
await act(async () => {
|
||||
root = createRoot(container);
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Projects />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function projectLinkNames(container: HTMLElement): string[] {
|
||||
return Array.from(container.querySelectorAll<HTMLAnchorElement>("a[href^='/projects/']")).map((link) => {
|
||||
const title = link.querySelector("span.truncate");
|
||||
return title?.textContent ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
describe("Projects", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
mockProjectsApi.list.mockResolvedValue([
|
||||
project({
|
||||
id: "project-bravo",
|
||||
urlKey: "bravo",
|
||||
name: "Bravo",
|
||||
description: null,
|
||||
updatedAt: new Date("2026-05-02T00:00:00Z"),
|
||||
}),
|
||||
project({
|
||||
id: "project-alpha",
|
||||
urlKey: "alpha",
|
||||
name: "Alpha",
|
||||
description: "First project",
|
||||
updatedAt: new Date("2026-05-01T00:00:00Z"),
|
||||
}),
|
||||
project({
|
||||
id: "project-charlie",
|
||||
urlKey: "charlie",
|
||||
name: "Charlie",
|
||||
description: null,
|
||||
updatedAt: new Date("2026-05-03T00:00:00Z"),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
root = null;
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sorts projects by name by default and can switch sort mode", async () => {
|
||||
root = await renderProjects(container);
|
||||
|
||||
expect(projectLinkNames(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
|
||||
|
||||
const select = container.querySelector("select");
|
||||
expect(select).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
select!.value = "updated";
|
||||
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(projectLinkNames(container)).toEqual(["Charlie", "Bravo", "Alpha"]);
|
||||
});
|
||||
|
||||
it("reserves description line height for projects without descriptions", async () => {
|
||||
root = await renderProjects(container);
|
||||
|
||||
const bravoLink = Array.from(container.querySelectorAll<HTMLAnchorElement>("a")).find((link) =>
|
||||
link.textContent?.includes("Bravo"),
|
||||
);
|
||||
const hiddenDescriptionLine = bravoLink?.querySelector("p[aria-hidden='true']");
|
||||
|
||||
expect(hiddenDescriptionLine).not.toBeNull();
|
||||
expect(hiddenDescriptionLine?.className).toContain("min-h-4");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Project } from "@paperclipai/shared";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useDialogActions } from "../context/DialogContext";
|
||||
|
|
@ -11,12 +12,67 @@ import { EmptyState } from "../components/EmptyState";
|
|||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { formatDate, projectUrl } from "../lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Hexagon, Plus } from "lucide-react";
|
||||
import { ArrowUpDown, Hexagon, Plus } from "lucide-react";
|
||||
|
||||
type ProjectSortMode = "name" | "updated" | "targetDate" | "status";
|
||||
|
||||
const PROJECT_SORT_LABELS: Record<ProjectSortMode, string> = {
|
||||
name: "Name",
|
||||
updated: "Recently updated",
|
||||
targetDate: "Target date",
|
||||
status: "Status",
|
||||
};
|
||||
|
||||
const PROJECT_STATUS_RANK: Record<Project["status"], number> = {
|
||||
in_progress: 0,
|
||||
planned: 1,
|
||||
backlog: 2,
|
||||
completed: 3,
|
||||
cancelled: 4,
|
||||
};
|
||||
|
||||
function projectTime(project: Project, field: "createdAt" | "updatedAt"): number {
|
||||
const value = project[field];
|
||||
const time = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
function compareProjectNames(left: Project, right: Project): number {
|
||||
const nameDiff = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
|
||||
return nameDiff !== 0 ? nameDiff : left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function compareTargetDates(left: Project, right: Project): number {
|
||||
if (!left.targetDate && !right.targetDate) return compareProjectNames(left, right);
|
||||
if (!left.targetDate) return 1;
|
||||
if (!right.targetDate) return -1;
|
||||
|
||||
const dateDiff = left.targetDate.localeCompare(right.targetDate);
|
||||
return dateDiff !== 0 ? dateDiff : compareProjectNames(left, right);
|
||||
}
|
||||
|
||||
function sortProjects(projects: Project[], sortMode: ProjectSortMode): Project[] {
|
||||
return [...projects].sort((left, right) => {
|
||||
if (sortMode === "updated") {
|
||||
const updatedDiff = projectTime(right, "updatedAt") - projectTime(left, "updatedAt");
|
||||
return updatedDiff !== 0 ? updatedDiff : compareProjectNames(left, right);
|
||||
}
|
||||
if (sortMode === "targetDate") {
|
||||
return compareTargetDates(left, right);
|
||||
}
|
||||
if (sortMode === "status") {
|
||||
const statusDiff = PROJECT_STATUS_RANK[left.status] - PROJECT_STATUS_RANK[right.status];
|
||||
return statusDiff !== 0 ? statusDiff : compareProjectNames(left, right);
|
||||
}
|
||||
return compareProjectNames(left, right);
|
||||
});
|
||||
}
|
||||
|
||||
export function Projects() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { openNewProject } = useDialogActions();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const [sortMode, setSortMode] = useState<ProjectSortMode>("name");
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Projects" }]);
|
||||
|
|
@ -31,6 +87,10 @@ export function Projects() {
|
|||
() => (allProjects ?? []).filter((p) => !p.archivedAt),
|
||||
[allProjects],
|
||||
);
|
||||
const sortedProjects = useMemo(
|
||||
() => sortProjects(projects, sortMode),
|
||||
[projects, sortMode],
|
||||
);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
|
||||
|
|
@ -42,7 +102,22 @@ export function Projects() {
|
|||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
<span>Sort</span>
|
||||
<select
|
||||
className="rounded-md border border-border bg-background px-2.5 py-1.5 text-sm text-foreground outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring"
|
||||
value={sortMode}
|
||||
onChange={(event) => setSortMode(event.target.value as ProjectSortMode)}
|
||||
>
|
||||
{(Object.keys(PROJECT_SORT_LABELS) as ProjectSortMode[]).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{PROJECT_SORT_LABELS[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button size="sm" variant="outline" onClick={openNewProject}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Project
|
||||
|
|
@ -62,11 +137,12 @@ export function Projects() {
|
|||
|
||||
{projects.length > 0 && (
|
||||
<div className="border border-border">
|
||||
{projects.map((project) => (
|
||||
{sortedProjects.map((project) => (
|
||||
<EntityRow
|
||||
key={project.id}
|
||||
title={project.name}
|
||||
subtitle={project.description ?? undefined}
|
||||
reserveSubtitleSpace
|
||||
to={projectUrl(project)}
|
||||
trailing={
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue