mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
feat: add plugin submission success modal (#3141)
Add the missing plugin submission success state, align plugin and skill success icons with the muted marketplace treatment, and harden pending-publish and public URL fallback behavior. Validated with real full-stack browser proof, focused tests, maintainer review, and all required checks green. Vercel remains the expected contributor authorization failure. Co-authored-by: Nancy <nancymxgao@gmail.com> Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
This commit is contained in:
co-authored by
vyctorbrzezowski
parent
db3b3fe920
commit
aaa73625ed
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { DocsLinks } from "clawhub-schema";
|
||||
import { getFunctionName } from "convex/server";
|
||||
import { createElement } from "react";
|
||||
@@ -33,6 +33,7 @@ vi.mock("sonner", () => ({
|
||||
const generateUploadUrl = vi.fn();
|
||||
const publishRelease = vi.fn();
|
||||
const fetchMock = vi.fn();
|
||||
const writeTextMock = vi.fn();
|
||||
const useAuthStatusMock = vi.fn();
|
||||
const useQueryMock = vi.fn();
|
||||
const useSearchMock = vi.fn();
|
||||
@@ -78,6 +79,38 @@ function makeCodePluginPackageJson(overrides: Record<string, unknown>) {
|
||||
});
|
||||
}
|
||||
|
||||
function uploadCodePluginPackage(
|
||||
packageJsonOverrides: Record<string, unknown>,
|
||||
directory = "demo-plugin",
|
||||
) {
|
||||
const packageJson = withRelativePath(
|
||||
new File([makeCodePluginPackageJson(packageJsonOverrides)], "package.json", {
|
||||
type: "application/json",
|
||||
}),
|
||||
`${directory}/package.json`,
|
||||
);
|
||||
const manifest = withRelativePath(
|
||||
new File(['{"id":"demo.plugin"}'], "openclaw.plugin.json", { type: "application/json" }),
|
||||
`${directory}/openclaw.plugin.json`,
|
||||
);
|
||||
fireEvent.change(getFileInput(), { target: { files: [packageJson, manifest] } });
|
||||
}
|
||||
|
||||
function makeVintageAyuMembership(
|
||||
overrides: { kind?: "user" | "org"; role?: "owner" | "publisher" } = {},
|
||||
) {
|
||||
return {
|
||||
publisher: {
|
||||
_id: "publishers:vintageayu",
|
||||
handle: "vintageayu",
|
||||
displayName: "VintageAyu",
|
||||
kind: overrides.kind ?? "user",
|
||||
image: "/clawd-logo.png",
|
||||
},
|
||||
role: overrides.role ?? "owner",
|
||||
};
|
||||
}
|
||||
|
||||
function getFileInput() {
|
||||
const input = document.querySelector('input[type="file"]');
|
||||
if (!(input instanceof HTMLInputElement)) throw new Error("Missing file input");
|
||||
@@ -102,6 +135,7 @@ describe("plugins publish route", () => {
|
||||
generateUploadUrl.mockReset();
|
||||
publishRelease.mockReset();
|
||||
fetchMock.mockReset();
|
||||
writeTextMock.mockReset();
|
||||
useAuthStatusMock.mockReset();
|
||||
useQueryMock.mockReset();
|
||||
useSearchMock.mockReset();
|
||||
@@ -125,18 +159,7 @@ describe("plugins publish route", () => {
|
||||
if (args === "skip") return undefined;
|
||||
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
|
||||
if (name !== "publishers:listMine") return null;
|
||||
return [
|
||||
{
|
||||
publisher: {
|
||||
_id: "publishers:vintageayu",
|
||||
handle: "vintageayu",
|
||||
displayName: "VintageAyu",
|
||||
kind: "user",
|
||||
image: "/clawd-logo.png",
|
||||
},
|
||||
role: "owner",
|
||||
},
|
||||
];
|
||||
return [makeVintageAyuMembership()];
|
||||
});
|
||||
generateUploadUrl.mockResolvedValue("https://upload.local");
|
||||
publishRelease.mockResolvedValue({ ok: true, packageId: "pkg:1", releaseId: "rel:1" });
|
||||
@@ -151,6 +174,12 @@ describe("plugins publish route", () => {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: writeTextMock.mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -319,6 +348,163 @@ describe("plugins publish route", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the submitted plugin modal with plugin identity and canonical actions", async () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
ownerHandle: "@VintageAyu",
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
});
|
||||
renderPublishRoute();
|
||||
|
||||
uploadCodePluginPackage({
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.3",
|
||||
repository: "https://github.com/openclaw/demo-plugin.git",
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue("demo-plugin")).toBeTruthy();
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
|
||||
target: { value: "abc123" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Plugin submitted" })).toBeTruthy();
|
||||
const submittedDialog = within(screen.getByRole("dialog"));
|
||||
expect(submittedDialog.getByText("Your plugin is under review")).toBeTruthy();
|
||||
expect(submittedDialog.getByText("Demo Plugin")).toBeTruthy();
|
||||
expect(submittedDialog.getByText("VintageAyu")).toBeTruthy();
|
||||
expect(submittedDialog.getByText("@vintageayu")).toBeTruthy();
|
||||
|
||||
const pluginLink = screen.getByRole("link", {
|
||||
name: "clawhub.ai/vintageayu/plugins/demo-plugin",
|
||||
});
|
||||
expect(pluginLink.getAttribute("href")).toBe(
|
||||
"https://clawhub.ai/vintageayu/plugins/demo-plugin",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy plugin link" }));
|
||||
await waitFor(() => {
|
||||
expect(writeTextMock).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai/vintageayu/plugins/demo-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
const viewPlugin = screen.getByRole("link", { name: "View plugin" });
|
||||
expect(viewPlugin.getAttribute("href")).toBe("/vintageayu/plugins/demo-plugin");
|
||||
expect(publishRelease).toHaveBeenCalledWith({
|
||||
payload: expect.objectContaining({ ownerHandle: "vintageayu" }),
|
||||
});
|
||||
expect(screen.queryByRole("link", { name: /Share on Discord/i })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /Share on Twitter/i })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("heading", { name: "Plugin submitted" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps publish disabled until a publisher identity resolves", async () => {
|
||||
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
|
||||
if (name === "publishers:listMine") return undefined;
|
||||
return null;
|
||||
});
|
||||
renderPublishRoute();
|
||||
|
||||
uploadCodePluginPackage({ name: "demo-plugin", version: "1.0.0" });
|
||||
|
||||
expect(await screen.findByText("Loading publishing identities…")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
|
||||
).not.toBeNull();
|
||||
expect(publishRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps publish disabled when the requested publisher is not available", async () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
ownerHandle: "not-a-member",
|
||||
name: undefined,
|
||||
displayName: undefined,
|
||||
family: undefined,
|
||||
nextVersion: undefined,
|
||||
sourceRepo: undefined,
|
||||
});
|
||||
|
||||
renderPublishRoute();
|
||||
|
||||
uploadCodePluginPackage({ name: "demo-plugin", version: "1.0.0" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Full commit SHA").getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
|
||||
target: { value: "abc123" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("Select an available publisher to publish.")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
|
||||
).not.toBeNull();
|
||||
expect(publishRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips the package-page lookup for backend-reserved package names", async () => {
|
||||
let getByNameCalls = 0;
|
||||
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
|
||||
if (name === "packages:getByName") {
|
||||
getByNameCalls += 1;
|
||||
throw new Error("Reserved package names must not be queried");
|
||||
}
|
||||
if (name === "publishers:listMine") {
|
||||
return [makeVintageAyuMembership()];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
renderPublishRoute();
|
||||
|
||||
uploadCodePluginPackage({ name: "publish", version: "1.0.0" }, "publish");
|
||||
|
||||
expect(await screen.findByDisplayValue("publish")).toBeTruthy();
|
||||
expect(getByNameCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps an existing-plugin publish disabled until its context resolves", () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
ownerHandle: "vintageayu",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
nextVersion: "1.2.4",
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
});
|
||||
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
|
||||
if (name === "packages:getManageContext") return undefined;
|
||||
if (name === "publishers:listMine") {
|
||||
return [makeVintageAyuMembership()];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
renderPublishRoute();
|
||||
|
||||
expect(screen.getByText("Loading plugin details…")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
|
||||
).not.toBeNull();
|
||||
expect(publishRelease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefills and preserves catalog metadata when publishing a new plugin version", async () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
ownerHandle: "vintageayu",
|
||||
@@ -343,7 +529,9 @@ describe("plugins publish route", () => {
|
||||
suggestedCategories: [],
|
||||
};
|
||||
}
|
||||
if (name === "publishers:listMine") return [];
|
||||
if (name === "publishers:listMine") {
|
||||
return [makeVintageAyuMembership()];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -418,7 +606,9 @@ describe("plugins publish route", () => {
|
||||
suggestedCategories: [],
|
||||
};
|
||||
}
|
||||
if (name === "publishers:listMine") return [];
|
||||
if (name === "publishers:listMine") {
|
||||
return [makeVintageAyuMembership()];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -884,6 +1074,108 @@ describe("plugins publish route", () => {
|
||||
});
|
||||
expect(screen.queryByText(/Running TruffleHog and ClawScan/i)).toBeNull();
|
||||
expect(screen.queryByText("Publishing release...")).toBeNull();
|
||||
expect(screen.queryByRole("heading", { name: "Plugin submitted" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the submitted modal for a staged version of an existing plugin", async () => {
|
||||
useSearchMock.mockReturnValue({
|
||||
ownerHandle: "vintageayu",
|
||||
name: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
nextVersion: "1.2.4",
|
||||
sourceRepo: "openclaw/demo-plugin",
|
||||
});
|
||||
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
|
||||
if (name === "packages:getManageContext") {
|
||||
return {
|
||||
package: { name: "demo-plugin", displayName: "Demo Plugin" },
|
||||
latestRelease: { version: "1.2.3" },
|
||||
suggestedCategories: [],
|
||||
};
|
||||
}
|
||||
if (name === "publishers:listMine") {
|
||||
return [makeVintageAyuMembership()];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
publishRelease.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: "pending",
|
||||
attemptId: "publishAttempts:2",
|
||||
packageName: "demo-plugin",
|
||||
version: "1.2.4",
|
||||
});
|
||||
renderPublishRoute();
|
||||
|
||||
uploadCodePluginPackage({ name: "demo-plugin", version: "1.2.4" });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Full commit SHA").getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
|
||||
target: { value: "abc123" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Plugin submitted" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "View plugin" }).getAttribute("href")).toBe(
|
||||
"/vintageayu/plugins/demo-plugin",
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.getByText("Publish received. Security checks are running.")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Publish plugin" }).getAttribute("disabled"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows a canonical submitted modal for an existing plugin uploaded from the generic route", async () => {
|
||||
useQueryMock.mockImplementation((fn: unknown, args: unknown) => {
|
||||
if (args === "skip") return undefined;
|
||||
const name = fn ? getFunctionName(fn as Parameters<typeof getFunctionName>[0]) : "";
|
||||
if (name === "packages:getByName") {
|
||||
return {
|
||||
package: { name: "demo-plugin", displayName: "Demo Plugin" },
|
||||
latestRelease: { version: "1.2.3" },
|
||||
owner: { handle: "vintageayu" },
|
||||
};
|
||||
}
|
||||
if (name === "publishers:listMine") {
|
||||
return [makeVintageAyuMembership({ kind: "org", role: "publisher" })];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
publishRelease.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: "pending",
|
||||
attemptId: "publishAttempts:3",
|
||||
packageName: "demo-plugin",
|
||||
version: "1.2.4",
|
||||
});
|
||||
renderPublishRoute();
|
||||
|
||||
uploadCodePluginPackage({
|
||||
name: "Demo-Plugin",
|
||||
displayName: "Demo Plugin",
|
||||
version: "1.2.4",
|
||||
repository: "https://github.com/openclaw/demo-plugin.git",
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Full commit SHA").getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("Full commit SHA"), {
|
||||
target: { value: "abc123" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Publish plugin" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Plugin submitted" })).toBeTruthy();
|
||||
expect(screen.getByRole("link", { name: "View plugin" }).getAttribute("href")).toBe(
|
||||
"/vintageayu/plugins/demo-plugin",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: "clawhub.ai/vintageayu/plugins/demo-plugin" }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("warns when README references relative image paths but no source repo/commit is set", async () => {
|
||||
|
||||
@@ -71,4 +71,14 @@ describe("MarketplaceIcon", () => {
|
||||
expect(glyph?.classList.contains("lucide-package")).toBe(true);
|
||||
expect(glyph?.classList.contains("lucide-slash")).toBe(false);
|
||||
});
|
||||
|
||||
it("exposes a muted treatment for neutral marketplace contexts", () => {
|
||||
const { container } = render(
|
||||
<MarketplaceIcon kind="plugin" label="Muted Plugin" tone="muted" />,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector(".marketplace-icon")?.classList.contains("marketplace-icon-muted"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ type MarketplaceIconProps = {
|
||||
summary?: string | null;
|
||||
} | null;
|
||||
size?: "xs" | "sm" | "md";
|
||||
tone?: "default" | "muted";
|
||||
};
|
||||
|
||||
const TONES = [
|
||||
@@ -42,6 +43,7 @@ export function MarketplaceIcon({
|
||||
categorySlug,
|
||||
skill,
|
||||
size = "sm",
|
||||
tone = "default",
|
||||
}: MarketplaceIconProps) {
|
||||
const [failedImageUrl, setFailedImageUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -56,16 +58,18 @@ export function MarketplaceIcon({
|
||||
: kind === "plugin" && pluginCategory
|
||||
? (getCategoryIconComponent(pluginCategory.icon) ?? MARKETPLACE_KIND_ICONS.plugin)
|
||||
: MARKETPLACE_KIND_ICONS[kind];
|
||||
const tone = hashTone(label);
|
||||
const hashedTone = hashTone(label);
|
||||
const visibleImageUrl = imageUrl && failedImageUrl !== imageUrl ? imageUrl : null;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`marketplace-icon marketplace-icon-${kind} marketplace-icon-${size}`}
|
||||
className={`marketplace-icon marketplace-icon-${kind} marketplace-icon-${size}${
|
||||
tone === "muted" ? " marketplace-icon-muted" : ""
|
||||
}`}
|
||||
style={
|
||||
{
|
||||
"--marketplace-icon-accent": tone.accent,
|
||||
"--marketplace-icon-wash": tone.wash,
|
||||
"--marketplace-icon-accent": hashedTone.accent,
|
||||
"--marketplace-icon-wash": hashedTone.wash,
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PluginPublishSubmittedDialog } from "./PluginPublishSubmittedDialog";
|
||||
|
||||
const writeTextMock = vi.fn();
|
||||
|
||||
function renderDialog(overrides: Partial<Parameters<typeof PluginPublishSubmittedDialog>[0]> = {}) {
|
||||
return render(
|
||||
<PluginPublishSubmittedDialog
|
||||
isOpen
|
||||
plugin={{
|
||||
name: "Demo Plugin",
|
||||
path: "/vintageayu/plugins/demo-plugin",
|
||||
publisher: { displayName: "VintageAyu", handle: "vintageayu" },
|
||||
}}
|
||||
onDismiss={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("PluginPublishSubmittedDialog", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
writeTextMock.mockReset();
|
||||
writeTextMock.mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: writeTextMock },
|
||||
});
|
||||
});
|
||||
|
||||
it("moves focus into the dialog instead of a secondary action", async () => {
|
||||
renderDialog();
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toBe(dialog);
|
||||
});
|
||||
expect(screen.getByRole("button", { name: "Copy plugin link" })).not.toBe(
|
||||
document.activeElement,
|
||||
);
|
||||
expect(document.querySelector(".marketplace-icon-muted")).toBeTruthy();
|
||||
});
|
||||
|
||||
it.each(["http://127.0.0.1:3030", "http://localhost:3030", "http://[::1]:3030"])(
|
||||
"uses the public ClawHub URL instead of local dev origin %s",
|
||||
(localOrigin) => {
|
||||
vi.stubEnv("VITE_SITE_URL", localOrigin);
|
||||
|
||||
renderDialog();
|
||||
|
||||
const pluginLink = screen.getByRole("link", {
|
||||
name: "clawhub.ai/vintageayu/plugins/demo-plugin",
|
||||
});
|
||||
expect(pluginLink.getAttribute("href")).toBe(
|
||||
"https://clawhub.ai/vintageayu/plugins/demo-plugin",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("copies the canonical plugin link and confirms success", async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy plugin link" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(writeTextMock).toHaveBeenCalledWith(
|
||||
"https://clawhub.ai/vintageayu/plugins/demo-plugin",
|
||||
);
|
||||
});
|
||||
expect(await screen.findByRole("button", { name: "Copied plugin link" })).toBeTruthy();
|
||||
expect(screen.getByText("Copied")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows clipboard failures and allows retry", async () => {
|
||||
writeTextMock.mockRejectedValueOnce(new Error("Clipboard unavailable"));
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Copy plugin link" }));
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Plugin link copy failed" })).toBeTruthy();
|
||||
expect(screen.getByText("Copy failed")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Plugin link copy failed" }));
|
||||
expect(await screen.findByRole("button", { name: "Copied plugin link" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("dismisses from the dialog close control", () => {
|
||||
const onDismiss = vi.fn();
|
||||
renderDialog({ onDismiss });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { ArrowRight, Check, Code2, Copy, FileText, Package, Wrench } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getPublicClawHubSiteUrl } from "../lib/site";
|
||||
import { copyText } from "./InstallCopyButton";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
|
||||
|
||||
type CopyState = "idle" | "copied" | "failed";
|
||||
|
||||
export type SubmittedPlugin = {
|
||||
name: string;
|
||||
path: string;
|
||||
publisher: {
|
||||
displayName?: string | null;
|
||||
handle?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PluginPublishSubmittedDialogProps = {
|
||||
isOpen: boolean;
|
||||
plugin: SubmittedPlugin;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
function buildAbsolutePluginUrl(pluginPath: string) {
|
||||
return new URL(pluginPath, getPublicClawHubSiteUrl()).toString();
|
||||
}
|
||||
|
||||
export function PluginPublishSubmittedDialog({
|
||||
isOpen,
|
||||
plugin,
|
||||
onDismiss,
|
||||
}: PluginPublishSubmittedDialogProps) {
|
||||
const { name: pluginName, path: pluginPath, publisher } = plugin;
|
||||
const [copyState, setCopyState] = useState<CopyState>("idle");
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const hasDismissedRef = useRef(false);
|
||||
const dialogContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const pluginUrl = useMemo(() => buildAbsolutePluginUrl(pluginPath), [pluginPath]);
|
||||
const compactPluginUrl = useMemo(() => pluginUrl.replace(/^https?:\/\//, ""), [pluginUrl]);
|
||||
const publisherDisplayName = publisher?.displayName?.trim() || null;
|
||||
const publisherHandle = publisher?.handle?.trim() || null;
|
||||
const publisherLabel = publisherDisplayName || (publisherHandle ? `@${publisherHandle}` : null);
|
||||
const copyButtonLabel =
|
||||
copyState === "copied"
|
||||
? "Copied plugin link"
|
||||
: copyState === "failed"
|
||||
? "Plugin link copy failed"
|
||||
: "Copy plugin link";
|
||||
const copyButtonText =
|
||||
copyState === "copied" ? "Copied" : copyState === "failed" ? "Copy failed" : "Copy link";
|
||||
const CopyButtonIcon = copyState === "copied" ? Check : Copy;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCopyState("idle");
|
||||
setDismissed(false);
|
||||
hasDismissedRef.current = false;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
function dismiss() {
|
||||
if (hasDismissedRef.current) return;
|
||||
hasDismissedRef.current = true;
|
||||
setDismissed(true);
|
||||
onDismiss();
|
||||
}
|
||||
|
||||
async function copyPluginLink() {
|
||||
try {
|
||||
const didCopy = await copyText(pluginUrl);
|
||||
setCopyState(didCopy ? "copied" : "failed");
|
||||
} catch {
|
||||
setCopyState("failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen && !dismissed}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) dismiss();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
ref={dialogContentRef}
|
||||
tabIndex={-1}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
dialogContentRef.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
onEscapeKeyDown={dismiss}
|
||||
onInteractOutside={dismiss}
|
||||
className="[--publish-accent:var(--oc-status-success-fg)] [display:block] w-[min(calc(100vw-2rem),620px)] overflow-hidden rounded-[var(--oc-radius-surface)] border-[color:var(--oc-border-subtle)] bg-[color:var(--oc-bg-elevated)] p-0 shadow-[var(--oc-shadow-lg)] focus:outline-none sm:p-0"
|
||||
style={{ display: "block" }}
|
||||
>
|
||||
<div className="relative w-full overflow-hidden">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-[9%] top-0 z-20 h-px bg-[linear-gradient(90deg,transparent_0%,color-mix(in_srgb,var(--publish-accent)_28%,transparent)_20%,color-mix(in_srgb,var(--publish-accent)_72%,transparent)_50%,color-mix(in_srgb,var(--publish-accent)_28%,transparent)_80%,transparent_100%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-44 bg-[radial-gradient(70%_72%_at_50%_0%,color-mix(in_srgb,var(--publish-accent)_11%,transparent)_0%,color-mix(in_srgb,var(--publish-accent)_5%,transparent)_42%,transparent_82%)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-col gap-4 p-5 pt-6 pb-5 sm:p-7 sm:pt-8 sm:pb-6">
|
||||
<div className="relative overflow-hidden rounded-[var(--radius-md)] px-6 py-5 text-center sm:px-10">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 hidden overflow-hidden rounded-[inherit] text-[color:var(--ink-soft)] sm:block"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<FileText
|
||||
className="absolute left-[9%] top-[22%] h-5 w-5 -rotate-12 opacity-18"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<Code2
|
||||
className="absolute left-[16%] bottom-[20%] h-5 w-5 rotate-8 opacity-14"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<Wrench
|
||||
className="absolute right-[10%] top-[23%] h-5 w-5 rotate-12 opacity-16"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
<Package
|
||||
className="absolute right-[16%] bottom-[20%] h-5 w-5 -rotate-10 opacity-14"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<DialogTitle className="inline-flex items-center justify-center gap-2 text-[1.35rem] leading-tight sm:text-[1.45rem]">
|
||||
Plugin submitted
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mx-auto mt-1.5 max-w-[31rem] text-[0.8125rem] leading-[1.4] [text-wrap:balance]">
|
||||
Your plugin is under review
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="relative z-10 rounded-t-[var(--radius-md)] rounded-b-[var(--radius-sm)] border border-[color:var(--line)] bg-[color:color-mix(in_srgb,var(--surface-muted)_78%,var(--surface))] px-3.5 pb-3.5 pt-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<MarketplaceIcon kind="plugin" label={pluginName} tone="muted" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[0.8125rem] font-bold text-[color:var(--ink)]">
|
||||
{pluginName}
|
||||
</p>
|
||||
{publisherLabel ? (
|
||||
<div className="mt-1.5 flex min-w-0 items-center gap-1.5 text-xs text-[color:var(--ink-soft)]">
|
||||
<span className="min-w-0 truncate font-medium">{publisherLabel}</span>
|
||||
{publisherDisplayName && publisherHandle ? (
|
||||
<>
|
||||
<span
|
||||
className="shrink-0 leading-none text-[color:color-mix(in_srgb,var(--ink-soft)_66%,var(--surface))]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
·
|
||||
</span>
|
||||
<span className="shrink-0 text-[color:color-mix(in_srgb,var(--ink-soft)_82%,var(--surface))]">
|
||||
@{publisherHandle}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="-mt-2 flex min-w-0 items-center gap-2 rounded-b-[var(--oc-radius-surface)] border border-t-0 border-[color:color-mix(in_srgb,var(--oc-border-subtle)_74%,transparent)] bg-[color:var(--oc-bg-surface)] px-3.5 pb-2 pt-4">
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-full bg-[color:color-mix(in_srgb,var(--publish-accent)_42%,transparent)]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<a
|
||||
href={pluginUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block min-w-0 flex-1 truncate text-xs font-medium text-[color:color-mix(in_srgb,var(--ink-soft)_68%,var(--surface))] !no-underline hover:!no-underline hover:text-[color:var(--ink-soft)]"
|
||||
>
|
||||
{compactPluginUrl}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 shrink-0 items-center gap-1 rounded-[var(--radius-sm)] px-2 text-xs font-semibold text-[color:var(--ink-soft)] transition hover:bg-[color:var(--surface-muted)] hover:text-[color:var(--ink)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--publish-accent)]/30"
|
||||
aria-label={copyButtonLabel}
|
||||
onClick={() => void copyPluginLink()}
|
||||
>
|
||||
<CopyButtonIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span aria-live="polite">{copyButtonText}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
asChild
|
||||
className="min-h-0 border-transparent bg-transparent p-0 text-[color:var(--ink-soft)] hover:not-disabled:border-transparent hover:not-disabled:bg-transparent hover:not-disabled:text-[color:var(--ink)]"
|
||||
>
|
||||
<a href={pluginPath}>
|
||||
View plugin
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ describe("SkillPublishSuccessDialog", () => {
|
||||
expect(screen.getByText("Developer tools")).toBeTruthy();
|
||||
expect(screen.getByText("#skills")).toBeTruthy();
|
||||
expect(screen.getByText("Friends of the Crustacean 🦞🤝")).toBeTruthy();
|
||||
expect(document.querySelector(".marketplace-icon-muted")).toBeTruthy();
|
||||
|
||||
const discordLink = screen.getByRole("link", { name: /Share on Discord/i });
|
||||
expect(discordLink.getAttribute("href")).toBe(OPENCLAW_SKILLS_DISCORD_URL);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getClawHubSiteUrl } from "../lib/site";
|
||||
import { getPublicClawHubSiteUrl } from "../lib/site";
|
||||
import { cn } from "../lib/utils";
|
||||
import { copyText } from "./InstallCopyButton";
|
||||
import { MarketplaceIcon } from "./MarketplaceIcon";
|
||||
@@ -19,9 +19,6 @@ import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dial
|
||||
|
||||
export const OPENCLAW_SKILLS_DISCORD_URL =
|
||||
"https://discord.com/channels/1456350064065904867/1456891440897724637";
|
||||
const PUBLIC_CLAWHUB_SITE_URL = "https://clawhub.ai";
|
||||
const LOCAL_SHARE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
|
||||
|
||||
type CopyState = "idle" | "copied" | "failed";
|
||||
|
||||
type SkillPublishSuccessDialogProps = {
|
||||
@@ -48,17 +45,6 @@ type SkillPublishSuccessDialogProps = {
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
function getPublicClawHubSiteUrl() {
|
||||
const configured = getClawHubSiteUrl();
|
||||
try {
|
||||
const hostname = new URL(configured).hostname;
|
||||
if (LOCAL_SHARE_HOSTS.has(hostname)) return PUBLIC_CLAWHUB_SITE_URL;
|
||||
} catch {
|
||||
return PUBLIC_CLAWHUB_SITE_URL;
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
function buildAbsoluteSkillUrl(skillPath: string) {
|
||||
return new URL(skillPath, getPublicClawHubSiteUrl()).toString();
|
||||
}
|
||||
@@ -199,6 +185,7 @@ export function SkillPublishSuccessDialog({
|
||||
label={displayName}
|
||||
icon={skill?.icon}
|
||||
skill={skill}
|
||||
tone="muted"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3">
|
||||
|
||||
+13
-1
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { getClawHubSiteUrl, normalizeClawHubSiteOrigin } from "./site";
|
||||
import { getClawHubSiteUrl, getPublicClawHubSiteUrl, normalizeClawHubSiteOrigin } from "./site";
|
||||
|
||||
function withServerEnv<T>(values: Record<string, string | undefined>, run: () => T): T {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
@@ -62,4 +62,16 @@ describe("site helpers", () => {
|
||||
expect(getClawHubSiteUrl()).toBe("https://clawhub.ai");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps shareable URLs public during local development", () => {
|
||||
withServerEnv({ VITE_SITE_URL: "http://localhost:3030" }, () => {
|
||||
expect(getPublicClawHubSiteUrl()).toBe("https://clawhub.ai");
|
||||
});
|
||||
withServerEnv({ VITE_SITE_URL: "https://example.com" }, () => {
|
||||
expect(getPublicClawHubSiteUrl()).toBe("https://example.com");
|
||||
});
|
||||
withServerEnv({ VITE_SITE_URL: "file:///tmp/clawhub" }, () => {
|
||||
expect(getPublicClawHubSiteUrl()).toBe("https://clawhub.ai");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getRuntimeEnv } from "./runtimeEnv";
|
||||
|
||||
const DEFAULT_CLAWHUB_SITE_URL = "https://clawhub.ai";
|
||||
const LEGACY_CLAWDHUB_HOSTS = new Set(["clawdhub.com", "www.clawdhub.com", "auth.clawdhub.com"]);
|
||||
const LOCAL_SITE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "[::1]"]);
|
||||
|
||||
export const SITE_NAME = "ClawHub";
|
||||
export const SITE_DESCRIPTION = "ClawHub — a fast skill registry for agents, with vector search.";
|
||||
@@ -22,3 +23,14 @@ export function normalizeClawHubSiteOrigin(value?: string | null) {
|
||||
export function getClawHubSiteUrl() {
|
||||
return normalizeClawHubSiteOrigin(getRuntimeEnv("VITE_SITE_URL")) ?? DEFAULT_CLAWHUB_SITE_URL;
|
||||
}
|
||||
|
||||
export function getPublicClawHubSiteUrl() {
|
||||
const configured = getClawHubSiteUrl();
|
||||
try {
|
||||
return LOCAL_SITE_HOSTS.has(new URL(configured).hostname)
|
||||
? DEFAULT_CLAWHUB_SITE_URL
|
||||
: configured;
|
||||
} catch {
|
||||
return DEFAULT_CLAWHUB_SITE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
+132
-16
@@ -18,6 +18,10 @@ import {
|
||||
PackageSourceChooser,
|
||||
type PackagePickSource,
|
||||
} from "../../components/PackageSourceChooser";
|
||||
import {
|
||||
PluginPublishSubmittedDialog,
|
||||
type SubmittedPlugin,
|
||||
} from "../../components/PluginPublishSubmittedDialog";
|
||||
import {
|
||||
PublisherOwnerSelect,
|
||||
type PublisherOwnerMembership,
|
||||
@@ -41,6 +45,7 @@ import {
|
||||
normalizePackageUploadFiles,
|
||||
} from "../../lib/packageUpload";
|
||||
import { derivePluginPrefill, listPrefilledFields } from "../../lib/pluginPublishPrefill";
|
||||
import { buildPluginDetailHref, displayPluginPackageName } from "../../lib/pluginRoutes";
|
||||
import { buildReadmeAssetBaseUrl } from "../../lib/readmeAssetBaseUrl";
|
||||
import { expandFilesWithReport } from "../../lib/uploadFiles";
|
||||
import { useAuthStatus } from "../../lib/useAuthStatus";
|
||||
@@ -70,6 +75,29 @@ const apiRefs = api as unknown as {
|
||||
const SHOW_CLAWPACK_ONBOARDING_BANNER = false;
|
||||
const PLUGIN_PUBLISHING_GUIDE_URL = "https://docs.openclaw.ai/clawhub/publishing#plugins";
|
||||
|
||||
function normalizePublisherHandle(handle: string) {
|
||||
return handle.trim().replace(/^@+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function findPublisherMembership(
|
||||
publishers: PublisherOwnerMembership[] | undefined,
|
||||
handle: string,
|
||||
) {
|
||||
const normalizedHandle = normalizePublisherHandle(handle);
|
||||
if (!normalizedHandle) return null;
|
||||
return (
|
||||
publishers?.find(
|
||||
(membership) => normalizePublisherHandle(membership.publisher.handle) === normalizedHandle,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePluginPackageName(name: string) {
|
||||
const normalized = name.trim().toLowerCase();
|
||||
if (normalized === "publish") return null;
|
||||
return /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(normalized) ? normalized : null;
|
||||
}
|
||||
|
||||
function findReadmeFile(files: File[]): File | null {
|
||||
// Match the same lookup the publish backend uses (readme.md / readme.mdx)
|
||||
// by going through the shared upload-path normalizer so we see the exact
|
||||
@@ -118,6 +146,24 @@ type ParsedInspectorPublishError = {
|
||||
findings: Array<{ code: string; message: string }>;
|
||||
};
|
||||
|
||||
type PluginPublishResult = {
|
||||
isPending: boolean;
|
||||
hasPluginPage: boolean;
|
||||
packageName: string | null;
|
||||
};
|
||||
|
||||
function parsePluginPublishResult(result: unknown): PluginPublishResult {
|
||||
if (result === null || typeof result !== "object") {
|
||||
return { isPending: false, hasPluginPage: false, packageName: null };
|
||||
}
|
||||
return {
|
||||
isPending: "status" in result && result.status === "pending",
|
||||
hasPluginPage: "packageId" in result && typeof result.packageId === "string",
|
||||
packageName:
|
||||
"packageName" in result && typeof result.packageName === "string" ? result.packageName : null,
|
||||
};
|
||||
}
|
||||
|
||||
const PLUGIN_INSPECTOR_BLOCKED_PREFIX = "Plugin Inspector blocked publish:";
|
||||
|
||||
function parsePluginInspectorPublishError(message: string): ParsedInspectorPublishError | null {
|
||||
@@ -231,8 +277,19 @@ export function PublishPluginRoute() {
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submittedPlugin, setSubmittedPlugin] = useState<SubmittedPlugin | null>(null);
|
||||
const showChangelogField = Boolean(search.name);
|
||||
|
||||
const canonicalDraftName = useMemo(() => normalizePluginPackageName(name), [name]);
|
||||
const existingPluginPage = useQuery(
|
||||
api.packages.getByName,
|
||||
me && canonicalDraftName ? { name: canonicalDraftName } : "skip",
|
||||
);
|
||||
|
||||
const selectedPublisher = useMemo(() => {
|
||||
return findPublisherMembership(publishers, ownerHandle)?.publisher ?? null;
|
||||
}, [ownerHandle, publishers]);
|
||||
|
||||
const totalBytes = useMemo(() => files.reduce((sum, file) => sum + file.size, 0), [files]);
|
||||
const normalizedPaths = useMemo(
|
||||
() => normalizePackageUploadFiles(files).map((entry) => entry.path),
|
||||
@@ -260,8 +317,10 @@ export function PublishPluginRoute() {
|
||||
const isNewPluginPublishEmpty = files.length === 0 && !search.name;
|
||||
const metadataDisabled = isMetadataLocked || isSubmitting;
|
||||
const ownerScopeError = useMemo(() => {
|
||||
return getPackageScopeOwnerMismatch(name, ownerHandle)?.message ?? null;
|
||||
}, [name, ownerHandle]);
|
||||
return (
|
||||
getPackageScopeOwnerMismatch(name, selectedPublisher?.handle ?? ownerHandle)?.message ?? null
|
||||
);
|
||||
}, [name, ownerHandle, selectedPublisher]);
|
||||
const submitBlockers = useMemo(() => {
|
||||
if (isMetadataLocked) return [];
|
||||
const blockers: string[] = [];
|
||||
@@ -277,8 +336,18 @@ export function PublishPluginRoute() {
|
||||
Boolean(validationError) || Boolean(ownerScopeError) || codePluginFieldIssues.length > 0;
|
||||
const hasPublished =
|
||||
status?.startsWith("Published.") || status?.startsWith("Publish received.") || false;
|
||||
const isPublisherLoading = me !== null && me !== undefined && publishers === undefined;
|
||||
const isExistingContextLoading = Boolean(me && search.name && existing === undefined);
|
||||
const isPluginPageLookupLoading = Boolean(
|
||||
me && files.length > 0 && canonicalDraftName && existingPluginPage === undefined,
|
||||
);
|
||||
const isPublishDisabled =
|
||||
!isAuthenticated ||
|
||||
isPublisherLoading ||
|
||||
isExistingContextLoading ||
|
||||
isPluginPageLookupLoading ||
|
||||
!ownerHandle ||
|
||||
!selectedPublisher ||
|
||||
isMetadataLocked ||
|
||||
hasPackageBlocker ||
|
||||
submitBlockers.length > 0 ||
|
||||
@@ -287,6 +356,11 @@ export function PublishPluginRoute() {
|
||||
const publishBlockerSummary = useMemo(() => {
|
||||
if (isSubmitting) return null;
|
||||
if (!isAuthenticated) return "Sign in to publish.";
|
||||
if (isPublisherLoading) return "Loading publishing identities…";
|
||||
if (isExistingContextLoading) return "Loading plugin details…";
|
||||
if (isPluginPageLookupLoading) return "Checking plugin page…";
|
||||
if (!ownerHandle) return "Select a publisher to publish.";
|
||||
if (!selectedPublisher) return "Select an available publisher to publish.";
|
||||
if (isMetadataLocked) return "Complete plugin files to publish.";
|
||||
if (validationError) return `Fix: ${validationError}`;
|
||||
if (ownerScopeError) return `Fix: ${ownerScopeError}`;
|
||||
@@ -302,9 +376,14 @@ export function PublishPluginRoute() {
|
||||
}, [
|
||||
codePluginFieldIssues,
|
||||
isAuthenticated,
|
||||
isExistingContextLoading,
|
||||
isMetadataLocked,
|
||||
isPluginPageLookupLoading,
|
||||
isPublisherLoading,
|
||||
isSubmitting,
|
||||
ownerHandle,
|
||||
ownerScopeError,
|
||||
selectedPublisher,
|
||||
submitBlockers,
|
||||
validationError,
|
||||
]);
|
||||
@@ -360,6 +439,7 @@ export function PublishPluginRoute() {
|
||||
setIgnoredPaths(nextIgnoredPaths);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
setSubmittedPlugin(null);
|
||||
setReadmeAssetReport(await scanReadmeRelativeAssets(filtered.files));
|
||||
const prefill = await derivePluginPrefill(normalized);
|
||||
setDetectedPrefillFields(listPrefilledFields(prefill));
|
||||
@@ -389,14 +469,16 @@ export function PublishPluginRoute() {
|
||||
setReadmeAssetReport(EMPTY_README_ASSET_REPORT);
|
||||
setError(null);
|
||||
setStatus(null);
|
||||
setSubmittedPlugin(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ownerHandle) return;
|
||||
const personal =
|
||||
publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0];
|
||||
if (personal?.publisher.handle) {
|
||||
setOwnerHandle(personal.publisher.handle);
|
||||
const matchingMembership = ownerHandle
|
||||
? findPublisherMembership(publishers, ownerHandle)
|
||||
: (publishers?.find((entry) => entry.publisher.kind === "user") ?? publishers?.[0]);
|
||||
const canonicalHandle = matchingMembership?.publisher.handle;
|
||||
if (canonicalHandle && ownerHandle !== canonicalHandle) {
|
||||
setOwnerHandle(canonicalHandle);
|
||||
}
|
||||
}, [ownerHandle, publishers]);
|
||||
|
||||
@@ -812,6 +894,10 @@ export function PublishPluginRoute() {
|
||||
toast.error(validationError);
|
||||
return;
|
||||
}
|
||||
if (!selectedPublisher) {
|
||||
toast.error("Select an available publisher to publish.");
|
||||
return;
|
||||
}
|
||||
if (ownerScopeError) {
|
||||
toast.error(ownerScopeError);
|
||||
return;
|
||||
@@ -833,7 +919,7 @@ export function PublishPluginRoute() {
|
||||
payload: {
|
||||
name: name.trim(),
|
||||
displayName: displayName.trim() || undefined,
|
||||
ownerHandle: ownerHandle || undefined,
|
||||
ownerHandle: selectedPublisher.handle,
|
||||
family,
|
||||
version: version.trim(),
|
||||
changelog: changelog.trim(),
|
||||
@@ -872,19 +958,42 @@ export function PublishPluginRoute() {
|
||||
files: uploaded,
|
||||
},
|
||||
});
|
||||
if (
|
||||
result &&
|
||||
typeof result === "object" &&
|
||||
"status" in result &&
|
||||
result.status === "pending"
|
||||
) {
|
||||
setStatus(null);
|
||||
void navigate({ to: "/dashboard" });
|
||||
const publishResult = parsePluginPublishResult(result);
|
||||
const submittedPackageName =
|
||||
publishResult.packageName ?? canonicalDraftName;
|
||||
const existingPluginHasPage = Boolean(
|
||||
submittedPackageName &&
|
||||
(existingPluginPage?.package?.name === submittedPackageName ||
|
||||
existing?.package?.name === submittedPackageName),
|
||||
);
|
||||
const hasSubmittedPluginPage =
|
||||
publishResult.hasPluginPage || existingPluginHasPage;
|
||||
if (publishResult.isPending) {
|
||||
if (hasSubmittedPluginPage) {
|
||||
setStatus("Publish received. Security checks are running.");
|
||||
} else {
|
||||
setStatus(null);
|
||||
void navigate({ to: "/dashboard" });
|
||||
}
|
||||
} else {
|
||||
setStatus(
|
||||
"Published. Pending security checks and verification before public listing.",
|
||||
);
|
||||
}
|
||||
if (hasSubmittedPluginPage) {
|
||||
setSubmittedPlugin({
|
||||
name:
|
||||
displayName.trim() ||
|
||||
displayPluginPackageName(submittedPackageName ?? name.trim()),
|
||||
path: buildPluginDetailHref(submittedPackageName ?? name.trim(), {
|
||||
ownerHandle: selectedPublisher.handle,
|
||||
}),
|
||||
publisher: {
|
||||
displayName: selectedPublisher.displayName,
|
||||
handle: selectedPublisher.handle,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (publishError) {
|
||||
const message = formatPublishError(publishError);
|
||||
setError(message);
|
||||
@@ -908,6 +1017,13 @@ export function PublishPluginRoute() {
|
||||
</div>
|
||||
) : null}
|
||||
</Container>
|
||||
{submittedPlugin ? (
|
||||
<PluginPublishSubmittedDialog
|
||||
isOpen
|
||||
plugin={submittedPlugin}
|
||||
onDismiss={() => setSubmittedPlugin(null)}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3910,6 +3910,15 @@ code {
|
||||
box-shadow: 0 4px 12px color-mix(in srgb, var(--marketplace-icon-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.marketplace-icon-muted,
|
||||
.marketplace-icon-muted:hover {
|
||||
background: color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
border-color: color-mix(in srgb, var(--ink) 7%, transparent);
|
||||
color: var(--ink-soft);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.marketplace-icon-xs {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
|
||||
Reference in New Issue
Block a user