[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:
+3
-2
@@ -30,10 +30,10 @@ import { Activity } from "./pages/Activity";
|
||||
import { Inbox } from "./pages/Inbox";
|
||||
import { CompanySettings } from "./pages/CompanySettings";
|
||||
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
|
||||
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
|
||||
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
|
||||
import { CloudUpstream } from "./pages/CloudUpstream";
|
||||
import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab";
|
||||
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
|
||||
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
|
||||
import { CompanyInvites } from "./pages/CompanyInvites";
|
||||
import { CompanySkills } from "./pages/CompanySkills";
|
||||
import { Secrets } from "./pages/Secrets";
|
||||
@@ -72,6 +72,7 @@ function boardRoutes() {
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
|
||||
<Route path="company/settings/members" element={<CompanyAccess />} />
|
||||
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
|
||||
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
|
||||
|
||||
@@ -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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -10,10 +9,10 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn());
|
||||
const mockSidebarBadgesApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
}));
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getExperimental: vi.fn(),
|
||||
}));
|
||||
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({
|
||||
@@ -65,22 +64,28 @@ vi.mock("@/api/sidebarBadges", () => ({
|
||||
sidebarBadgesApi: mockSidebarBadgesApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: mockUsePluginSlots,
|
||||
}));
|
||||
|
||||
vi.mock("@/api/instanceSettings", () => ({
|
||||
instanceSettingsApi: mockInstanceSettingsApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/plugins/slots", () => ({
|
||||
usePluginSlots: mockUsePluginSlots,
|
||||
}));
|
||||
|
||||
// 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 () => {
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("CompanySettingsSidebar", () => {
|
||||
@@ -95,6 +100,9 @@ describe("CompanySettingsSidebar", () => {
|
||||
failedRuns: 0,
|
||||
joinRequests: 2,
|
||||
});
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableCloudSync: false,
|
||||
});
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [],
|
||||
isLoading: false,
|
||||
@@ -130,6 +138,7 @@ describe("CompanySettingsSidebar", () => {
|
||||
expect(container.textContent).toContain("Company Settings");
|
||||
expect(container.textContent).toContain("General");
|
||||
expect(container.textContent).toContain("Environments");
|
||||
expect(container.textContent).not.toContain("Cloud upstream");
|
||||
expect(container.textContent).toContain("Members");
|
||||
expect(container.textContent).not.toContain("Cloud upstream");
|
||||
expect(container.textContent).toContain("Invites");
|
||||
@@ -176,6 +185,38 @@ describe("CompanySettingsSidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows cloud upstream only when cloud sync is enabled", async () => {
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
|
||||
enableCloudSync: true,
|
||||
});
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CompanySettingsSidebar />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(container.textContent).toContain("Cloud upstream");
|
||||
expect(sidebarNavItemMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "/company/settings/cloud-upstream",
|
||||
label: "Cloud upstream",
|
||||
end: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders company settings pages contributed by ready plugins", async () => {
|
||||
mockUsePluginSlots.mockReturnValue({
|
||||
slots: [
|
||||
|
||||
@@ -12,6 +12,7 @@ interface EntityRowProps {
|
||||
to?: string;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
reserveSubtitleSpace?: boolean;
|
||||
}
|
||||
|
||||
export function EntityRow({
|
||||
@@ -24,6 +25,7 @@ export function EntityRow({
|
||||
to,
|
||||
onClick,
|
||||
className,
|
||||
reserveSubtitleSpace,
|
||||
}: EntityRowProps) {
|
||||
const isClickable = !!(to || onClick);
|
||||
const classes = cn(
|
||||
@@ -45,8 +47,13 @@ export function EntityRow({
|
||||
)}
|
||||
<span className="truncate">{title}</span>
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{subtitle}</p>
|
||||
{(subtitle || reserveSubtitleSpace) && (
|
||||
<p
|
||||
className={cn("text-xs text-muted-foreground truncate mt-0.5 min-h-4", !subtitle && "invisible")}
|
||||
aria-hidden={!subtitle}
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
|
||||
|
||||
@@ -450,8 +450,10 @@ describe("MarkdownBody", () => {
|
||||
|
||||
expect(html).toContain("paperclip-markdown-codeblock");
|
||||
expect(html).toContain("paperclip-markdown-codeblock-actions");
|
||||
expect(html).toContain("position:absolute;top:0.4rem;right:0.4rem;display:inline-flex");
|
||||
expect(html).toContain("paperclip-markdown-codeblock-wrap");
|
||||
expect(html).toContain('aria-label="Wrap lines"');
|
||||
expect(html).toContain("position:static;opacity:1;display:inline-flex");
|
||||
expect(html).toContain("paperclip-markdown-codeblock-copy");
|
||||
expect(html).toContain('aria-label="Copy code"');
|
||||
expect(html).toContain("lucide-copy");
|
||||
|
||||
@@ -84,6 +84,39 @@ const scrollableBlockStyle: React.CSSProperties = {
|
||||
overflowX: "auto",
|
||||
};
|
||||
|
||||
const codeBlockActionsStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: "0.4rem",
|
||||
right: "0.4rem",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.25rem",
|
||||
};
|
||||
|
||||
const codeBlockActionStyle: React.CSSProperties = {
|
||||
position: "static",
|
||||
opacity: 1,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.25rem",
|
||||
minHeight: "1.55rem",
|
||||
padding: "0.2rem 0.4rem",
|
||||
borderRadius: "calc(var(--radius) - 4px)",
|
||||
border: "1px solid color-mix(in oklab, var(--foreground) 14%, transparent)",
|
||||
backgroundColor: "color-mix(in oklab, var(--muted) 92%, var(--background) 8%)",
|
||||
color: "var(--muted-foreground)",
|
||||
fontSize: "0.7rem",
|
||||
lineHeight: 1,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
const codeBlockWrapActionStyle: React.CSSProperties = {
|
||||
...codeBlockActionStyle,
|
||||
width: "1.55rem",
|
||||
paddingInline: 0,
|
||||
};
|
||||
|
||||
const tableCellWrapStyle: React.CSSProperties = {
|
||||
overflowWrap: "anywhere",
|
||||
wordBreak: "normal",
|
||||
@@ -426,6 +459,7 @@ function CodeBlock({
|
||||
</pre>
|
||||
<div
|
||||
className="paperclip-markdown-codeblock-actions"
|
||||
style={codeBlockActionsStyle}
|
||||
data-active={copied || failed || wrapLines || undefined}
|
||||
>
|
||||
<button
|
||||
@@ -434,11 +468,17 @@ function CodeBlock({
|
||||
aria-label={wrapLabel}
|
||||
title={wrapLabel}
|
||||
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-wrap"
|
||||
style={wrapLines
|
||||
? {
|
||||
...codeBlockWrapActionStyle,
|
||||
borderColor: "color-mix(in oklab, var(--primary) 38%, transparent)",
|
||||
color: "var(--primary)",
|
||||
}
|
||||
: codeBlockWrapActionStyle}
|
||||
aria-pressed={wrapLines}
|
||||
data-active={wrapLines || undefined}
|
||||
>
|
||||
<WrapText aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
<span className="paperclip-markdown-codeblock-action-label">{wrapLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -446,6 +486,7 @@ function CodeBlock({
|
||||
aria-label="Copy code"
|
||||
title={copyLabel}
|
||||
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-copy"
|
||||
style={codeBlockActionStyle}
|
||||
data-copied={copied || undefined}
|
||||
data-failed={failed || undefined}
|
||||
>
|
||||
|
||||
@@ -52,6 +52,7 @@ const mockIssuesApi = vi.hoisted(() => ({
|
||||
|
||||
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listSummaries: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockProjectsApi = vi.hoisted(() => ({
|
||||
@@ -310,7 +311,9 @@ describe("NewIssueDialog", () => {
|
||||
mockIssuesApi.create.mockReset();
|
||||
mockIssuesApi.upsertDocument.mockReset();
|
||||
mockIssuesApi.uploadAttachment.mockReset();
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
|
||||
mockExecutionWorkspacesApi.list.mockReset();
|
||||
mockExecutionWorkspacesApi.listSummaries.mockReset();
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([
|
||||
{
|
||||
id: "project-1",
|
||||
@@ -382,13 +385,15 @@ describe("NewIssueDialog", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
||||
{
|
||||
id: "workspace-1",
|
||||
name: "Parent workspace",
|
||||
mode: "isolated_workspace",
|
||||
status: "active",
|
||||
branchName: "feature/pap-1",
|
||||
cwd: "/tmp/workspace-1",
|
||||
projectWorkspaceId: null,
|
||||
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
@@ -406,6 +411,15 @@ describe("NewIssueDialog", () => {
|
||||
const { root } = renderDialog(container);
|
||||
await flush();
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1", {
|
||||
projectId: "project-1",
|
||||
projectWorkspaceId: undefined,
|
||||
reuseEligible: true,
|
||||
});
|
||||
});
|
||||
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
|
||||
|
||||
const submitButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("Create Sub-Issue"));
|
||||
expect(submitButton).not.toBeUndefined();
|
||||
@@ -494,7 +508,7 @@ describe("NewIssueDialog", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
||||
{
|
||||
id: "workspace-1",
|
||||
name: "PAP-100",
|
||||
@@ -502,6 +516,7 @@ describe("NewIssueDialog", () => {
|
||||
status: "active",
|
||||
branchName: "feature/pap-100",
|
||||
cwd: "/tmp/workspace-1",
|
||||
projectWorkspaceId: "project-workspace-2",
|
||||
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
||||
},
|
||||
]);
|
||||
@@ -809,21 +824,25 @@ describe("NewIssueDialog", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
mockExecutionWorkspacesApi.list.mockResolvedValue([
|
||||
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
|
||||
{
|
||||
id: "workspace-1",
|
||||
name: "Parent workspace",
|
||||
mode: "isolated_workspace",
|
||||
status: "active",
|
||||
branchName: "feature/pap-1",
|
||||
cwd: "/tmp/workspace-1",
|
||||
projectWorkspaceId: null,
|
||||
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "workspace-2",
|
||||
name: "Other workspace",
|
||||
mode: "isolated_workspace",
|
||||
status: "active",
|
||||
branchName: "feature/pap-2",
|
||||
cwd: "/tmp/workspace-2",
|
||||
projectWorkspaceId: null,
|
||||
lastUsedAt: new Date("2026-04-06T16:01:00.000Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -469,13 +469,13 @@ export function NewIssueDialog() {
|
||||
enabled: !!effectiveCompanyId && newIssueOpen,
|
||||
});
|
||||
const { data: reusableExecutionWorkspaces } = useQuery({
|
||||
queryKey: queryKeys.executionWorkspaces.list(effectiveCompanyId!, {
|
||||
queryKey: queryKeys.executionWorkspaces.summaryList(effectiveCompanyId!, {
|
||||
projectId,
|
||||
projectWorkspaceId: projectWorkspaceId || undefined,
|
||||
reuseEligible: true,
|
||||
}),
|
||||
queryFn: () =>
|
||||
executionWorkspacesApi.list(effectiveCompanyId!, {
|
||||
executionWorkspacesApi.listSummaries(effectiveCompanyId!, {
|
||||
projectId,
|
||||
projectWorkspaceId: projectWorkspaceId || undefined,
|
||||
reuseEligible: true,
|
||||
|
||||
+4
-1
@@ -736,7 +736,9 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||
.paperclip-markdown-codeblock-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
min-height: 1.55rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: calc(var(--radius) - 4px);
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent);
|
||||
@@ -745,7 +747,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.12s ease, color 0.12s ease;
|
||||
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions,
|
||||
@@ -761,6 +763,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
|
||||
|
||||
.paperclip-markdown-codeblock-action[data-active],
|
||||
.paperclip-markdown-codeblock-action[data-copied] {
|
||||
border-color: color-mix(in oklab, var(--primary) 38%, transparent);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
PluginSlotMount,
|
||||
_collectRegisterableExportNamesForTests,
|
||||
_resetPluginModuleLoader,
|
||||
registerPluginWebComponent,
|
||||
type ResolvedPluginSlot,
|
||||
} from "./slots";
|
||||
|
||||
let roots: Root[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots) {
|
||||
flushSync(() => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
roots = [];
|
||||
_resetPluginModuleLoader();
|
||||
});
|
||||
|
||||
describe("plugin slot export registration", () => {
|
||||
it("keeps declared missing exports visible for diagnostics", () => {
|
||||
const exports = _collectRegisterableExportNamesForTests(
|
||||
{ Page: () => null },
|
||||
new Set(["Page", "MissingRouteSidebar"]),
|
||||
);
|
||||
|
||||
expect([...exports]).toEqual(["Page", "MissingRouteSidebar"]);
|
||||
});
|
||||
|
||||
it("registers component-like module exports even when the current contribution did not declare them", () => {
|
||||
const exports = _collectRegisterableExportNamesForTests(
|
||||
{
|
||||
Page: () => null,
|
||||
RouteSidebar: () => null,
|
||||
webComponentTag: "paperclip-widget",
|
||||
metadata: { ignored: true },
|
||||
count: 1,
|
||||
default: () => null,
|
||||
},
|
||||
new Set(["Page"]),
|
||||
);
|
||||
|
||||
expect(exports).toEqual(new Set(["Page", "RouteSidebar", "webComponentTag"]));
|
||||
});
|
||||
|
||||
it("updates an already-mounted placeholder when the slot export registers later", async () => {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const slot: ResolvedPluginSlot = {
|
||||
type: "routeSidebar",
|
||||
id: "content-machine-sidebar",
|
||||
displayName: "Content",
|
||||
exportName: "ContentMachineRouteSidebar",
|
||||
routePath: "content-machine",
|
||||
pluginId: "content-machine-plugin",
|
||||
pluginKey: "content-machine",
|
||||
pluginDisplayName: "Content Machine",
|
||||
pluginVersion: "1.0.0",
|
||||
};
|
||||
|
||||
flushSync(() => {
|
||||
root.render(createElement(PluginSlotMount, {
|
||||
slot,
|
||||
context: { companyId: "company-1", companyPrefix: "PAP" },
|
||||
missingBehavior: "placeholder",
|
||||
}));
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Content Machine: Content");
|
||||
|
||||
flushSync(() => {
|
||||
registerPluginWebComponent("content-machine", "ContentMachineRouteSidebar", "paperclip-test-sidebar");
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Content Machine: Content");
|
||||
expect(container.querySelector("paperclip-test-sidebar")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -124,11 +124,30 @@ type UsePluginSlotsResult = {
|
||||
* Keys are `${pluginKey}:${exportName}` to match manifest slot declarations.
|
||||
*/
|
||||
const registry = new Map<string, RegisteredPluginComponent>();
|
||||
const registryListeners = new Set<() => void>();
|
||||
|
||||
function buildRegistryKey(pluginKey: string, exportName: string): string {
|
||||
return `${pluginKey}:${exportName}`;
|
||||
}
|
||||
|
||||
function notifyRegistryListeners(): void {
|
||||
for (const listener of registryListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function usePluginRegistrySubscription(): void {
|
||||
const [, forceRerender] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = () => forceRerender((tick) => tick + 1);
|
||||
registryListeners.add(listener);
|
||||
return () => {
|
||||
registryListeners.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function requiresEntityType(slotType: PluginUiSlotType): boolean {
|
||||
return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem" || slotType === "toolbarButton";
|
||||
}
|
||||
@@ -150,6 +169,7 @@ export function registerPluginReactComponent(
|
||||
kind: "react",
|
||||
component,
|
||||
});
|
||||
notifyRegistryListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +184,7 @@ export function registerPluginWebComponent(
|
||||
kind: "web-component",
|
||||
tagName,
|
||||
});
|
||||
notifyRegistryListeners();
|
||||
}
|
||||
|
||||
function resolveRegisteredComponent(slot: ResolvedPluginSlot): RegisteredPluginComponent | null {
|
||||
@@ -177,6 +198,24 @@ export function resolveRegisteredPluginComponent(
|
||||
return registry.get(buildRegistryKey(pluginKey, exportName)) ?? null;
|
||||
}
|
||||
|
||||
function isRegisterablePluginExport(exported: unknown): boolean {
|
||||
return typeof exported === "function" || typeof exported === "string";
|
||||
}
|
||||
|
||||
function collectRegisterableExportNames(
|
||||
mod: Record<string, unknown>,
|
||||
declaredExports: Set<string>,
|
||||
): Set<string> {
|
||||
const exportNames = new Set(declaredExports);
|
||||
for (const [exportName, exported] of Object.entries(mod)) {
|
||||
if (exportName === "default") continue;
|
||||
if (isRegisterablePluginExport(exported)) {
|
||||
exportNames.add(exportName);
|
||||
}
|
||||
}
|
||||
return exportNames;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin module dynamic import loader
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -471,7 +510,8 @@ async function loadPluginModule(contribution: PluginUiContribution): Promise<voi
|
||||
}
|
||||
}
|
||||
|
||||
for (const exportName of declaredExports) {
|
||||
const exportNames = collectRegisterableExportNames(mod, declaredExports);
|
||||
for (const exportName of exportNames) {
|
||||
const exported = mod[exportName];
|
||||
if (exported === undefined) {
|
||||
console.warn(
|
||||
@@ -783,6 +823,7 @@ export function PluginSlotMount({
|
||||
className,
|
||||
missingBehavior = "hidden",
|
||||
}: PluginSlotMountProps) {
|
||||
usePluginRegistrySubscription();
|
||||
const [, forceRerender] = useState(0);
|
||||
const component = resolveRegisteredComponent(slot);
|
||||
|
||||
@@ -910,3 +951,4 @@ export function _resetPluginModuleLoader(): void {
|
||||
export const _applyJsxRuntimeKeyForTests = applyJsxRuntimeKey;
|
||||
export const _createReactShimSourceForTests = createReactShimSource;
|
||||
export const _rewriteBareSpecifiersForTests = rewriteBareSpecifiers;
|
||||
export const _collectRegisterableExportNamesForTests = collectRegisterableExportNames;
|
||||
|
||||
Reference in New Issue
Block a user