refactor: Reuse Builder link and menu permission helpers

This commit is contained in:
Oleg Isonen committed 2026-09-08 13:34:19 +01:00
1 parent e4a09fe7b8
commit 072a8dc534
3 files changed
+125 -98

No files matched your search

@@ -490,3 +490,38 @@ describe("getMenuPermissions", () => {
});
});
});
test.each([
{ instanceId: "child", count: 1, hasPage: true, allowed: true },
{ instanceId: "body", count: 1, hasPage: true, allowed: true },
{ instanceId: ROOT_INSTANCE_ID, count: 1, hasPage: true, allowed: false },
{ instanceId: "child", count: 2, hasPage: true, allowed: false },
{ instanceId: undefined, count: 0, hasPage: true, allowed: false },
{ instanceId: "child", count: 1, hasPage: false, allowed: false },
])(
"checks link availability in every editor mode for %j",
({ instanceId, count, hasPage, allowed }) => {
if (!hasPage) {
$selectedPageId.set(undefined);
}
for (const mode of ["design", "content", "preview"]) {
expect(
getMenuPermissions({
instancePath:
instanceId === undefined
? undefined
: [
{
instance: createInstance(instanceId),
instanceSelector: [instanceId],
},
],
selectedInstanceCount: count,
isDesignMode: mode === "design",
isContentMode: mode === "content",
instances: emptyInstances,
}).canCopyLink
).toBe(allowed);
}
}
);
@@ -19,6 +19,8 @@ import {
import { showAttribute } from "@webstudio-is/react-sdk";
import { emitCommand, instanceMoveCommandMetas } from "./commands";
import {
$authToken,
$builderMode,
$allSelectedInstanceSelectors,
$selectedInstancePath,
$selectedPage,
@@ -32,6 +34,7 @@ import {
type InstancePath,
} from "@webstudio-is/project-build/runtime";
import { canDeleteInstanceInContentMode } from "@webstudio-is/project-build/runtime";
import { builderPath } from "~/shared/router-utils";
import { $instances } from "~/shared/sync/data-stores";
import {
isComponentDetachable,
@@ -76,6 +79,11 @@ const getMenuPermissions = ({
const isRoot = instanceId === ROOT_INSTANCE_ID;
const isBody = instanceId === rootInstanceId;
const isRootOrBody = isRoot || isBody;
const canCopyLink =
selectedInstanceCount === 1 &&
instanceId !== undefined &&
!isRoot &&
rootInstanceId !== undefined;
const hasActionableSelection = selectedInstancePaths.some((path) => {
const selectedInstance = path[0]?.instance;
const selectedInstanceId = selectedInstance?.id;
@@ -90,6 +98,7 @@ const getMenuPermissions = ({
if (isContentMode) {
return {
canCopyLink,
canCopy: hasActionableSelection,
canPaste: hasActionableSelection,
canCut: false,
@@ -111,6 +120,7 @@ const getMenuPermissions = ({
canMutateDesign && instancePath ? canUnwrapInstance(instancePath) : false;
return {
canCopyLink,
canCopy: canMutateDesign && hasActionableSelection,
canPaste: canMutateDesign && !isRoot,
canCut: canMutateDesign && hasActionableSelection,
@@ -129,31 +139,6 @@ const getMenuPermissions = ({
};
};
const getInstanceLink = ({
url,
pageId,
instanceSelector,
}: {
url: string;
pageId: string | undefined;
instanceSelector: readonly string[] | undefined;
}) => {
if (
pageId === undefined ||
instanceSelector === undefined ||
instanceSelector.length === 0 ||
instanceSelector[0] === ROOT_INSTANCE_ID
) {
return;
}
const link = new URL(url);
link.searchParams.set("pageId", pageId);
link.searchParams.set("instance", instanceSelector.join(","));
link.searchParams.delete("pageHash");
link.hash = "";
return link.href;
};
export const MenuItems = () => {
const instancePath = useStore($selectedInstancePath);
const page = useStore($selectedPage);
@@ -202,21 +187,24 @@ export const MenuItems = () => {
</ContextMenuItemRightSlot>
</ContextMenuItem>
<ContextMenuItem
disabled={
selectedInstanceSelectors.length !== 1 ||
instanceSelector === undefined ||
instanceSelector[0] === ROOT_INSTANCE_ID ||
page === undefined
}
disabled={!permissions.canCopyLink}
onSelect={async () => {
const link = getInstanceLink({
url: window.location.href,
pageId: page?.id,
instanceSelector,
});
if (link === undefined) {
if (!permissions.canCopyLink) {
return;
}
const mode = $builderMode.get();
const link = new URL(
builderPath({
pageId: page?.id,
instanceSelector,
authToken: $authToken.get(),
mode: mode === "design" ? undefined : mode,
safemode:
new URLSearchParams(window.location.search).get("safemode") ===
"true",
}),
window.location.origin
).href;
try {
await navigator.clipboard.writeText(link);
toast.success("Link copied");
@@ -413,7 +401,6 @@ export const InstanceContextMenu = ({ children }: { children: ReactNode }) => {
};
export const __testing__ = {
getInstanceLink,
canDeleteInContentMode,
getMenuPermissions,
};
@@ -10,14 +10,15 @@ import {
} from "@webstudio-is/template";
import { $instances, $pages } from "~/shared/sync/data-stores";
import {
$authToken,
$builderMode,
$selectedPageId,
selectInstance,
selectInstances,
} from "~/shared/nano-states";
import { __testing__ as pageTesting } from "~/shared/pages/use-switch-page";
import { InstanceContextMenu, __testing__ } from "./instance-context-menu";
import { InstanceContextMenu } from "./instance-context-menu";
const { getInstanceLink } = __testing__;
const { getDeepLinkedInstanceSelection } = pageTesting;
const Body = createTemplateComponentFixture("Body");
const Box = createTemplateComponentFixture("Box");
@@ -46,76 +47,22 @@ const instanceSelector = ["target", "fragment", "slot-two", "body"];
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const originalUrl = window.location.href;
let root: Root | undefined;
afterEach(() => {
act(() => root?.unmount());
root = undefined;
document.body.innerHTML = "";
vi.restoreAllMocks();
window.history.replaceState(null, "", originalUrl);
$authToken.set(undefined);
$builderMode.set("design");
selectInstance(undefined);
$instances.set(new Map());
$pages.set(undefined);
$selectedPageId.set(undefined);
});
test("copies the current selection even before browser URL state catches up", () => {
const link = getInstanceLink({
url: "https://p-project.wstd.io/?pageId=old-page&instance=old-target&authToken=share-token&mode=content&pageHash=old-anchor#old-anchor",
pageId: "page",
instanceSelector,
});
const url = new URL(link!);
expect(url.origin).toBe("https://p-project.wstd.io");
expect(Object.fromEntries(url.searchParams)).toEqual({
pageId: "page",
instance: instanceSelector.join(","),
authToken: "share-token",
mode: "content",
});
expect(url.hash).toBe("");
const selector = url.searchParams.get("instance")!.split(",");
expect(
getDeepLinkedInstanceSelection({
instanceSelector: selector,
canOpenPageTemplates: true,
pages,
instances,
})
).toEqual({ pageId: "page", instanceSelector });
// inspect-instance uses this same runtime implementation. Its instanceId is
// the first entry, not the full selector or the browser's stale selection.
expect(
inspectInstance(
{ instances },
{
instanceId: selector[0],
include: ["children", "ancestors"],
}
)
).toMatchObject({ id: "target", component: "Box", children: [] });
});
test("includes an explicit Body selection and page id", () => {
const link = getInstanceLink({
url: "http://localhost:3000/",
pageId: "page",
instanceSelector: ["body"],
});
expect(new URL(link!).searchParams.get("instance")).toBe("body");
expect(new URL(link!).searchParams.get("pageId")).toBe("page");
});
test.each([
{ pageId: undefined, instanceSelector },
{ pageId: "page", instanceSelector: undefined },
{ pageId: "page", instanceSelector: [] },
{ pageId: "page", instanceSelector: [ROOT_INSTANCE_ID] },
])("does not create an unresolvable link for %j", (selection) => {
expect(
getInstanceLink({ url: "https://p-project.wstd.io/", ...selection })
).toBeUndefined();
});
const openMenu = (selectors: string[][] = [instanceSelector]) => {
$pages.set(pages);
$selectedPageId.set("page");
@@ -171,3 +118,61 @@ test("disables copying a Global Root link", () => {
true
);
});
test("copies current state and resolves the linked Slot occurrence and MCP target", async () => {
window.history.replaceState(
null,
"",
"?pageId=old-page&instance=old-target&mode=preview&safemode=true&pageHash=old-anchor#old-anchor"
);
$authToken.set("share-token");
$builderMode.set("content");
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue();
const item = openMenu();
await act(async () => item.click());
expect(writeText).toHaveBeenCalledOnce();
const url = new URL(writeText.mock.calls[0][0]);
expect(url.origin).toBe(window.location.origin);
expect(Object.fromEntries(url.searchParams)).toEqual({
pageId: "page",
instance: instanceSelector.join(","),
authToken: "share-token",
mode: "content",
safemode: "true",
});
expect(url.hash).toBe("");
const selector = url.searchParams.get("instance")!.split(",");
expect(
getDeepLinkedInstanceSelection({
instanceSelector: selector,
canOpenPageTemplates: true,
pages,
instances,
})
).toEqual({ pageId: "page", instanceSelector });
// inspect-instance uses this same runtime implementation. Its instanceId is
// the first entry, not the full selector or the browser's stale selection.
expect(
inspectInstance(
{ instances },
{
instanceId: selector[0],
include: ["children", "ancestors"],
}
)
).toMatchObject({ id: "target", component: "Box", children: [] });
});
test("copies an explicit Body selection", async () => {
const writeText = vi
.spyOn(navigator.clipboard, "writeText")
.mockResolvedValue();
const item = openMenu([["body"]]);
await act(async () => item.click());
expect(writeText).toHaveBeenCalledOnce();
const url = new URL(writeText.mock.calls[0][0]);
expect(url.searchParams.get("instance")).toBe("body");
expect(url.searchParams.get("pageId")).toBe("page");
});