mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 13:20:54 +01:00
Fix invite link copy fallback
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
678ba3335e
commit
5abd398fdf
2 files changed
+123
-17
No files matched your search
@@ -198,7 +198,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",
|
||||
@@ -206,12 +210,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();
|
||||
|
||||
@@ -230,6 +234,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,6 +62,11 @@ export function CompanyInvites() {
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [latestInviteCopied]);
|
||||
|
||||
function selectLatestInviteUrl() {
|
||||
latestInviteInputRef.current?.focus();
|
||||
latestInviteInputRef.current?.select();
|
||||
}
|
||||
|
||||
async function copyInviteUrl(url: string) {
|
||||
try {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||
@@ -71,9 +77,36 @@ 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 = url;
|
||||
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);
|
||||
selectLatestInviteUrl();
|
||||
if (copied) return true;
|
||||
} catch {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
selectLatestInviteUrl();
|
||||
pushToast({
|
||||
title: "Clipboard unavailable",
|
||||
body: "Copy the invite URL manually from the field below.",
|
||||
body: "The invite URL is selected. Copy it manually from the field.",
|
||||
tone: "warn",
|
||||
});
|
||||
return false;
|
||||
@@ -251,17 +284,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 copyInviteUrl(latestInviteUrl);
|
||||
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" />
|
||||
|
||||
Reference in new issue
Block a user