mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
refactor: Reuse Builder link and menu permission helpers
This commit is contained in:
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");
|
||||
});
|
||||
Reference in new issue
Block a user