mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
## Summary - Keep the shared content-model validator strict during normal Builder authoring. - Report HTML and component nesting violations as publish warnings instead of blocking legacy projects. - Allow paste operations to retain violations already contained inside legacy fragments and surface them as warnings. - Continue rejecting new violations introduced at the paste destination boundary. - Remove obsolete empty content models from CodeText and Video so their runtime-supported, HTML-valid children pass validation. ## Root cause The new pre-publish audit applied current authoring constraints to historical project trees. Some legacy structures, including old Accordion triggers containing an h3 inside a button, were created by earlier Webstudio templates and cannot satisfy the current HTML model without migration. Blocking publish or paste would make untouched legacy content unusable. CodeText and Video also had metadata that rejected children their renderers intentionally support. Those verified metadata mismatches are corrected independently of the compatibility policy. ## Behavior - New invalid nesting remains prevented by normal authoring operations. - Existing invalid nesting produces a Publish warning and does not abort publishing. - Webstudio instance, HTML, JSX, and Webflow paste retain fragment-internal violations and show a paste warning. - Paste still validates the destination and cannot introduce a new invalid parent-child relationship. - Resource integrity and missing audit inputs still prevent publishing. ## Verification - [x] Legacy button containing h3 reports a publish warning - [x] Legacy invalid subtree pastes successfully with a warning - [x] New paste destination violations remain rejected or use the closest valid target - [x] Valid legacy CodeText children pass validation - [x] Valid Video source and track children pass validation - [x] Project-build matcher and insert-target tests - [x] Builder instance, HTML, JSX, and Webflow paste tests - [x] Project-build and Builder typechecks - [x] Focused oxlint and formatting checks
1712 lines
54 KiB
TypeScript
1712 lines
54 KiB
TypeScript
import { describe, test, expect, vi } from "vitest";
|
|
import { enableMapSet } from "immer";
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import type {
|
|
Instance,
|
|
Instances,
|
|
Prop,
|
|
Props,
|
|
DataSource,
|
|
DataSources,
|
|
Resource,
|
|
Resources,
|
|
} from "@webstudio-is/sdk";
|
|
import {
|
|
encodeDataSourceVariable,
|
|
collectionComponent,
|
|
coreMetas,
|
|
elementComponent,
|
|
blockTemplateComponent,
|
|
portalComponent,
|
|
} from "@webstudio-is/sdk";
|
|
import type { Project } from "@webstudio-is/project";
|
|
import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas";
|
|
import { componentMetas } from "@webstudio-is/sdk-components-registry/metas";
|
|
import { registerContainers } from "../sync/sync-stores";
|
|
import { $registeredComponentMetas } from "../nano-states";
|
|
import { $instances } from "~/shared/sync/data-stores";
|
|
import {
|
|
$dataSources,
|
|
$pages,
|
|
$project,
|
|
$props,
|
|
$resources,
|
|
} from "~/shared/sync/data-stores";
|
|
import { instanceText } from "./plugin-instance";
|
|
import { createDefaultPages } from "@webstudio-is/project-build";
|
|
import { selectInstance, selectInstances } from "~/shared/nano-states";
|
|
import { $allSelectedInstanceSelectors } from "~/shared/nano-states";
|
|
import { $selectedPageId } from "../nano-states/pages";
|
|
import * as instanceMutationUtils from "../instance-utils/mutation";
|
|
import {
|
|
expectSlotTreeIntegrity,
|
|
expectSlotsShareFragment,
|
|
} from "../slot-test-utils";
|
|
import { pasteHandled } from "./copy-paste";
|
|
import { initBuilderApi } from "../builder-api";
|
|
|
|
const expectString = expect.any(String) as unknown as string;
|
|
|
|
enableMapSet();
|
|
registerContainers();
|
|
|
|
$registeredComponentMetas.set(
|
|
new Map(Object.entries({ ...baseComponentMetas, ...coreMetas }))
|
|
);
|
|
$project.set({ id: "my-project" } as Project);
|
|
$pages.set(
|
|
createDefaultPages({
|
|
homePageId: "home-page",
|
|
rootInstanceId: "body0",
|
|
})
|
|
);
|
|
$selectedPageId.set("home-page");
|
|
|
|
const setPageRoot = (rootInstanceId: Instance["id"]) => {
|
|
$pages.set(
|
|
createDefaultPages({
|
|
homePageId: "home-page",
|
|
rootInstanceId,
|
|
})
|
|
);
|
|
$selectedPageId.set("home-page");
|
|
};
|
|
|
|
const createInstance = (
|
|
id: Instance["id"],
|
|
component: string,
|
|
children: Instance["children"]
|
|
): Instance => {
|
|
return { type: "instance", id, component, children };
|
|
};
|
|
|
|
const getIdValuePair = <T extends { id: string }>(item: T) =>
|
|
[item.id, item] as const;
|
|
|
|
const toMap = <T extends { id: string }>(list: T[]) =>
|
|
new Map(list.map(getIdValuePair));
|
|
|
|
const getMapDifference = <Type extends Map<unknown, unknown>>(
|
|
left: Type,
|
|
right: Type
|
|
): Type => {
|
|
const leftSet = new Set(left.keys());
|
|
const rightSet = new Set(right.keys());
|
|
const difference = new Map() as Type;
|
|
for (const [key, value] of left) {
|
|
if (rightSet.has(key) === false) {
|
|
difference.set(key, value);
|
|
}
|
|
}
|
|
for (const [key, value] of right) {
|
|
if (leftSet.has(key) === false) {
|
|
difference.set(key, value);
|
|
}
|
|
}
|
|
return difference;
|
|
};
|
|
|
|
describe("copy and cut guards", () => {
|
|
test("does not copy without a selected instance", () => {
|
|
selectInstance(undefined);
|
|
|
|
expect(instanceText.onCopy?.()).toBeUndefined();
|
|
});
|
|
|
|
test("does not cut without a selected instance", () => {
|
|
selectInstance(undefined);
|
|
|
|
expect(instanceText.onCut?.()).toBeUndefined();
|
|
});
|
|
|
|
test("does not copy or cut the selected root instance", () => {
|
|
const instances: Instances = toMap([
|
|
createInstance("body0", "Body", [{ type: "id", value: "box1" }]),
|
|
createInstance("box1", "Box", []),
|
|
] satisfies Instance[]);
|
|
$instances.set(instances);
|
|
selectInstance(["body0"]);
|
|
|
|
expect(instanceText.onCopy?.()).toBeUndefined();
|
|
expect(instanceText.onCut?.()).toBeUndefined();
|
|
expect($instances.get()).toEqual(instances);
|
|
});
|
|
|
|
test("copies multiple selected roots into one combined fragment", () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
|
|
const clipboardData = instanceText.onCopy?.();
|
|
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instances/v0.1": {
|
|
rootInstanceIds: ["box1", "box2"],
|
|
fragment: {
|
|
children: [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
],
|
|
instances: [
|
|
{ id: "box1", type: "instance", component: "Box", children: [] },
|
|
{ id: "box2", type: "instance", component: "Box", children: [] },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test("pastes multiple copied roots in order and selects pasted roots", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
]),
|
|
createInstance("box1", "Box", [{ type: "id", value: "child" }]),
|
|
createInstance("child", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["body0"]);
|
|
|
|
const result = await instanceText.onPaste?.(clipboardData);
|
|
expect(result).toEqual(pasteHandled);
|
|
|
|
const bodyChildren = $instances.get().get("body0")?.children;
|
|
const pastedRootIds = bodyChildren?.slice(2).map((child) => child.value);
|
|
expect(bodyChildren).toEqual([
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: expect.any(String) },
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
expect(pastedRootIds?.[0]).not.toBe("box1");
|
|
expect(pastedRootIds?.[1]).not.toBe("box2");
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([
|
|
[pastedRootIds?.[0], "body0"],
|
|
[pastedRootIds?.[1], "body0"],
|
|
]);
|
|
});
|
|
|
|
test("pastes a legacy invalid subtree with a warning", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "legacy-button" },
|
|
]),
|
|
{
|
|
...createInstance("legacy-button", elementComponent, [
|
|
{ type: "id", value: "legacy-heading" },
|
|
]),
|
|
tag: "button",
|
|
},
|
|
{
|
|
...createInstance("legacy-heading", elementComponent, []),
|
|
tag: "h3",
|
|
},
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["legacy-button", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["body0"]);
|
|
initBuilderApi();
|
|
const previousWarn = window.__webstudio__$__builderApi.toast.warn;
|
|
const warn = vi.fn();
|
|
window.__webstudio__$__builderApi.toast.warn = warn;
|
|
|
|
expect(await instanceText.onPaste?.(clipboardData)).toEqual(pasteHandled);
|
|
expect($instances.get().get("body0")?.children).toHaveLength(2);
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"Pasted with warning: Placing <h3> element inside a <button> violates HTML spec."
|
|
);
|
|
window.__webstudio__$__builderApi.toast.warn = previousWarn;
|
|
});
|
|
|
|
test("sanitizes multi-root clipboard root ids before paste", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
const clipboardData = JSON.parse(instanceText.onCopy?.() ?? "");
|
|
clipboardData["@webstudio/instances/v0.1"].rootInstanceIds = [
|
|
"box1",
|
|
"child",
|
|
"missing",
|
|
"box1",
|
|
];
|
|
selectInstance(["body0"]);
|
|
|
|
expect(await instanceText.onPaste?.(JSON.stringify(clipboardData))).toEqual(
|
|
pasteHandled
|
|
);
|
|
|
|
const bodyChildren = $instances.get().get("body0")?.children;
|
|
expect(bodyChildren).toEqual([
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
|
|
clipboardData["@webstudio/instances/v0.1"].rootInstanceIds = ["missing"];
|
|
expect(await instanceText.onPaste?.(JSON.stringify(clipboardData))).toEqual(
|
|
pasteHandled
|
|
);
|
|
expect($instances.get().get("body0")?.children).toEqual(bodyChildren);
|
|
});
|
|
|
|
test("writes only copyable roots when multi-copy skips selected roots", () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "template" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("template", blockTemplateComponent, []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["template", "body0"],
|
|
["box1", "body0"],
|
|
]);
|
|
|
|
const clipboardData = instanceText.onCopy?.();
|
|
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instances/v0.1": {
|
|
rootInstanceIds: ["box1"],
|
|
},
|
|
});
|
|
});
|
|
|
|
test("does not copy when no multi-selected root is copyable", () => {
|
|
const instances: Instances = toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "template1" },
|
|
{ type: "id", value: "template2" },
|
|
]),
|
|
createInstance("template1", blockTemplateComponent, []),
|
|
createInstance("template2", blockTemplateComponent, []),
|
|
] satisfies Instance[]);
|
|
$instances.set(instances);
|
|
selectInstances([
|
|
["template1", "body0"],
|
|
["template2", "body0"],
|
|
]);
|
|
|
|
expect(instanceText.onCopy?.()).toBeUndefined();
|
|
expect($instances.get()).toEqual(instances);
|
|
});
|
|
|
|
test("cuts multiple selected roots into one combined fragment and removes them", () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
|
|
const clipboardData = instanceText.onCut?.();
|
|
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instances/v0.1": {
|
|
rootInstanceIds: ["box1", "box2"],
|
|
},
|
|
});
|
|
expect($instances.get().get("body0")?.children).toEqual([]);
|
|
expect($instances.get().has("box1")).toBe(false);
|
|
expect($instances.get().has("box2")).toBe(false);
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([]);
|
|
});
|
|
|
|
test("cuts only copyable roots when multi-cut skips selected roots", () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "template" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("template", blockTemplateComponent, []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["template", "body0"],
|
|
["box1", "body0"],
|
|
]);
|
|
|
|
const clipboardData = instanceText.onCut?.();
|
|
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instances/v0.1": {
|
|
rootInstanceIds: ["box1"],
|
|
},
|
|
});
|
|
expect($instances.get().get("body0")?.children).toEqual([
|
|
{ type: "id", value: "template" },
|
|
]);
|
|
expect($instances.get().has("box1")).toBe(false);
|
|
expect($instances.get().has("template")).toBe(true);
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([]);
|
|
});
|
|
|
|
test("does not cut when no multi-selected root is copyable", () => {
|
|
const instances: Instances = toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "template1" },
|
|
{ type: "id", value: "template2" },
|
|
]),
|
|
createInstance("template1", blockTemplateComponent, []),
|
|
createInstance("template2", blockTemplateComponent, []),
|
|
] satisfies Instance[]);
|
|
$instances.set(instances);
|
|
selectInstances([
|
|
["template1", "body0"],
|
|
["template2", "body0"],
|
|
]);
|
|
|
|
expect(instanceText.onCut?.()).toBeUndefined();
|
|
expect($instances.get()).toEqual(instances);
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([
|
|
["template1", "body0"],
|
|
["template2", "body0"],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("paste target", () => {
|
|
test("pastes radix navigation menu fragment with viewport wrapper", async () => {
|
|
const previousMetas = $registeredComponentMetas.get();
|
|
$registeredComponentMetas.set(componentMetas);
|
|
$instances.set(toMap([createInstance("body0", "Body", [])]));
|
|
selectInstance(["body0"]);
|
|
|
|
const clipboardData = JSON.stringify({
|
|
"@webstudio/instance/v0.1": {
|
|
instanceSelector: ["nav", "source-parent"],
|
|
children: [{ type: "id", value: "nav" }],
|
|
instances: [
|
|
createInstance(
|
|
"nav",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenu",
|
|
[
|
|
{ type: "id", value: "list" },
|
|
{ type: "id", value: "viewport-container" },
|
|
]
|
|
),
|
|
createInstance(
|
|
"list",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenuList",
|
|
[{ type: "id", value: "item" }]
|
|
),
|
|
createInstance(
|
|
"item",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenuItem",
|
|
[
|
|
{ type: "id", value: "trigger" },
|
|
{ type: "id", value: "content" },
|
|
]
|
|
),
|
|
createInstance(
|
|
"trigger",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenuTrigger",
|
|
[{ type: "id", value: "button" }]
|
|
),
|
|
createInstance("button", "Button", [{ type: "id", value: "text" }]),
|
|
createInstance("text", "Text", [{ type: "text", value: "About" }]),
|
|
createInstance(
|
|
"content",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenuContent",
|
|
[{ type: "id", value: "content-box" }]
|
|
),
|
|
createInstance("content-box", "Box", [
|
|
{ type: "id", value: "content-link" },
|
|
]),
|
|
createInstance("content-link", "Link", [
|
|
{ type: "text", value: "Documentation" },
|
|
]),
|
|
{
|
|
...createInstance("viewport-container", "Box", [
|
|
{ type: "id", value: "viewport" },
|
|
]),
|
|
label: "Viewport Container",
|
|
},
|
|
createInstance(
|
|
"viewport",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenuViewport",
|
|
[]
|
|
),
|
|
],
|
|
assets: [],
|
|
dataSources: [],
|
|
resources: [],
|
|
props: [],
|
|
breakpoints: [],
|
|
styleSourceSelections: [],
|
|
styleSources: [],
|
|
styles: [],
|
|
},
|
|
});
|
|
|
|
const warn = vi.spyOn(toast, "warn").mockImplementation(() => "");
|
|
const result = await instanceText.onPaste?.(clipboardData);
|
|
expect(result).toEqual(pasteHandled);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
|
|
const [pastedNavId] =
|
|
$instances
|
|
.get()
|
|
.get("body0")
|
|
?.children.map((child) => child.value) ?? [];
|
|
expect($instances.get().get(pastedNavId ?? "")?.component).toBe(
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenu"
|
|
);
|
|
warn.mockRestore();
|
|
$registeredComponentMetas.set(previousMetas);
|
|
});
|
|
|
|
// body0
|
|
// box1
|
|
// box2
|
|
const instances: Instances = toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[]);
|
|
|
|
test("is inside selected instance", async () => {
|
|
$instances.set(instances);
|
|
selectInstance(["box1", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["box2", "body0"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const instancesDifference = getMapDifference(instances, $instances.get());
|
|
const [newBox1] = instancesDifference.keys();
|
|
expect($instances.get().get("box2")).toEqual(
|
|
createInstance("box2", "Box", [{ type: "id", value: newBox1 }])
|
|
);
|
|
expect(instancesDifference).toEqual(
|
|
toMap([createInstance(newBox1, "Box", [])])
|
|
);
|
|
});
|
|
|
|
test("is after selected instance when same as copied", async () => {
|
|
$instances.set(instances);
|
|
selectInstance(["box1", "body0"]);
|
|
await instanceText.onPaste?.(instanceText.onCopy?.() ?? "");
|
|
|
|
const instancesDifference = getMapDifference(instances, $instances.get());
|
|
const [newBox1] = instancesDifference.keys();
|
|
expect($instances.get().get("body0")).toEqual(
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: newBox1 },
|
|
{ type: "id", value: "box2" },
|
|
])
|
|
);
|
|
expect(instancesDifference).toEqual(
|
|
toMap([createInstance(newBox1, "Box", [])])
|
|
);
|
|
});
|
|
|
|
test("uses multi-selection paste target after the last selected sibling for old single-root clipboard data", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: "tail" },
|
|
]),
|
|
createInstance("source", "Box", []),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
createInstance("tail", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["source", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const bodyChildren = $instances.get().get("body0")?.children;
|
|
expect(bodyChildren).toEqual([
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: expect.any(String) },
|
|
{ type: "id", value: "tail" },
|
|
]);
|
|
const pastedId = bodyChildren?.[3]?.value;
|
|
expect(pastedId).not.toBe("source");
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([[pastedId, "body0"]]);
|
|
});
|
|
|
|
test("uses multi-selection paste target after the last selected sibling for multi-root clipboard data", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source1" },
|
|
{ type: "id", value: "source2" },
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: "tail" },
|
|
]),
|
|
createInstance("source1", "Box", []),
|
|
createInstance("source2", "Box", []),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
createInstance("tail", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["source1", "body0"],
|
|
["source2", "body0"],
|
|
]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const bodyChildren = $instances.get().get("body0")?.children;
|
|
expect(bodyChildren).toEqual([
|
|
{ type: "id", value: "source1" },
|
|
{ type: "id", value: "source2" },
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: expect.any(String) },
|
|
{ type: "id", value: expect.any(String) },
|
|
{ type: "id", value: "tail" },
|
|
]);
|
|
});
|
|
|
|
test("uses lowest common ancestor paste target for mixed-parent multi-selection", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "section" },
|
|
{ type: "id", value: "footer" },
|
|
]),
|
|
createInstance("source", "Box", []),
|
|
createInstance("section", "Box", [
|
|
{ type: "id", value: "group" },
|
|
{ type: "id", value: "aside" },
|
|
{ type: "id", value: "tail" },
|
|
]),
|
|
createInstance("group", "Box", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("aside", "Box", []),
|
|
createInstance("tail", "Box", []),
|
|
createInstance("footer", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["source", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstances([
|
|
["box", "group", "section", "body0"],
|
|
["aside", "section", "body0"],
|
|
]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expect($instances.get().get("section")?.children).toEqual([
|
|
{ type: "id", value: "group" },
|
|
{ type: "id", value: "aside" },
|
|
{ type: "id", value: "tail" },
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
expect($instances.get().get("body0")?.children).toEqual([
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "section" },
|
|
{ type: "id", value: "footer" },
|
|
]);
|
|
});
|
|
|
|
test("pastes into shared slot content", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("source", "Box", []),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["source", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
|
|
expect($instances.get().get("slot1")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("slot2")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("fragment")?.children).toEqual([
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
});
|
|
|
|
test("pastes into legacy shared slot content", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("source", "Box", []),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "box" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["source", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const fragmentId = expectSlotsShareFragment($instances.get(), [
|
|
"slot1",
|
|
"slot2",
|
|
]);
|
|
expect($instances.get().get("slot1")?.children).toEqual([
|
|
{ type: "id", value: fragmentId },
|
|
]);
|
|
expect($instances.get().get("slot2")?.children).toEqual([
|
|
{ type: "id", value: fragmentId },
|
|
]);
|
|
expect($instances.get().get(fragmentId ?? "")?.children).toEqual([
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
});
|
|
|
|
test("pastes after selected shared slot child in shared slot content", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "fragment", "slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.(instanceText.onCopy?.() ?? "");
|
|
|
|
expect($instances.get().get("slot1")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("slot2")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("fragment")?.children).toEqual([
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
});
|
|
|
|
test("pastes after selected legacy shared slot child in shared slot content", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("slot2", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("heading", "Heading", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.(instanceText.onCopy?.() ?? "");
|
|
|
|
const fragmentId = expectSlotsShareFragment($instances.get(), [
|
|
"slot1",
|
|
"slot2",
|
|
]);
|
|
expect($instances.get().get(fragmentId ?? "")?.children).toEqual([
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: expect.any(String) },
|
|
{ type: "id", value: "heading" },
|
|
]);
|
|
expectSlotTreeIntegrity($instances.get());
|
|
});
|
|
|
|
test("pastes after selected nested shared slot child in shared slot content", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
|
|
createInstance("div", "Box", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "div", "fragment", "slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.(instanceText.onCopy?.() ?? "");
|
|
|
|
expect($instances.get().get("slot1")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("slot2")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("fragment")?.children).toEqual([
|
|
{ type: "id", value: "div" },
|
|
]);
|
|
expect($instances.get().get("div")?.children).toEqual([
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
});
|
|
|
|
test("pastes into nested shared slot container", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("source", "Box", []),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
|
|
createInstance("div", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["source", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["div", "fragment", "slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expect($instances.get().get("slot1")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("slot2")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("fragment")?.children).toEqual([
|
|
{ type: "id", value: "div" },
|
|
]);
|
|
expect($instances.get().get("div")?.children).toEqual([
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
});
|
|
|
|
test("copies shared slot child and pastes independent copy outside", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
{ type: "id", value: "target" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("target", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "fragment", "slot1", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["target", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
|
|
const pastedBoxId = $instances.get().get("target")?.children[0]?.value;
|
|
expect(pastedBoxId).toEqual(expect.any(String));
|
|
expect(pastedBoxId).not.toBe("box");
|
|
expect($instances.get().get("slot1")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("slot2")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("fragment")?.children).toEqual([
|
|
{ type: "id", value: "box" },
|
|
]);
|
|
expect($instances.get().get(pastedBoxId ?? "")?.children).toEqual([]);
|
|
});
|
|
|
|
test("copies legacy shared slot child and pastes independent copy outside", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
{ type: "id", value: "target" },
|
|
]),
|
|
createInstance("slot1", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("slot2", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("heading", "Heading", []),
|
|
createInstance("target", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "slot1", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["target", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const pastedBoxId = $instances.get().get("target")?.children[0]?.value;
|
|
expect(pastedBoxId).toEqual(expect.any(String));
|
|
expect(pastedBoxId).not.toBe("box");
|
|
expect($instances.get().get(pastedBoxId ?? "")?.component).toBe("Box");
|
|
|
|
selectInstance(["box", "slot1", "body0"]);
|
|
instanceMutationUtils.convertInstance(elementComponent, "section");
|
|
|
|
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
|
|
expect($instances.get().get("box")?.component).toBe(elementComponent);
|
|
expect($instances.get().get("box")?.tag).toBe("section");
|
|
expect($instances.get().get(pastedBoxId ?? "")?.component).toBe("Box");
|
|
});
|
|
|
|
test("copies nested shared slot child with scoped data and pastes independent copy outside", async () => {
|
|
const dataSources: DataSources = toMap([
|
|
{
|
|
id: "boxVariable",
|
|
scopeInstanceId: "box",
|
|
type: "variable",
|
|
name: "Box Variable",
|
|
value: { type: "string", value: "value" },
|
|
},
|
|
] satisfies DataSource[]);
|
|
const props: Props = toMap([
|
|
{
|
|
id: "boxExpressionProp",
|
|
instanceId: "box",
|
|
name: "children",
|
|
type: "expression",
|
|
value: "$ws$dataSource$boxVariable",
|
|
},
|
|
{
|
|
id: "boxResourceProp",
|
|
instanceId: "box",
|
|
name: "resource",
|
|
type: "resource",
|
|
value: "boxResource",
|
|
},
|
|
] satisfies Prop[]);
|
|
const resources: Resources = toMap([
|
|
{
|
|
id: "boxResource",
|
|
name: "Box Resource",
|
|
method: "get",
|
|
url: "$ws$dataSource$boxVariable",
|
|
headers: [
|
|
{
|
|
name: "authorization",
|
|
value: "$ws$dataSource$boxVariable",
|
|
},
|
|
],
|
|
searchParams: [
|
|
{
|
|
name: "query",
|
|
value: "$ws$dataSource$boxVariable",
|
|
},
|
|
],
|
|
},
|
|
] satisfies Resource[]);
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
{ type: "id", value: "target" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
|
|
createInstance("div", "Box", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("target", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
$dataSources.set(dataSources);
|
|
$props.set(props);
|
|
$resources.set(resources);
|
|
selectInstance(["box", "div", "fragment", "slot1", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["target", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const pastedBoxId = $instances.get().get("target")?.children[0]?.value;
|
|
const dataSourcesDifference = getMapDifference(
|
|
dataSources,
|
|
$dataSources.get()
|
|
);
|
|
const [newDataSourceId] = dataSourcesDifference.keys();
|
|
const propsDifference = getMapDifference(props, $props.get());
|
|
const resourcesDifference = getMapDifference(resources, $resources.get());
|
|
const [newResourceId] = resourcesDifference.keys();
|
|
expect(pastedBoxId).toEqual(expect.any(String));
|
|
expect(pastedBoxId).not.toBe("box");
|
|
expect(dataSourcesDifference).toEqual(
|
|
toMap([
|
|
{
|
|
...dataSources.get("boxVariable"),
|
|
id: newDataSourceId,
|
|
scopeInstanceId: pastedBoxId,
|
|
},
|
|
])
|
|
);
|
|
expect(Array.from(propsDifference.values())).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
id: expectString,
|
|
instanceId: pastedBoxId,
|
|
name: "children",
|
|
type: "expression",
|
|
value: encodeDataSourceVariable(newDataSourceId),
|
|
},
|
|
{
|
|
id: expectString,
|
|
instanceId: pastedBoxId,
|
|
name: "resource",
|
|
type: "resource",
|
|
value: newResourceId,
|
|
},
|
|
])
|
|
);
|
|
expect(resourcesDifference).toEqual(
|
|
toMap([
|
|
{
|
|
id: newResourceId,
|
|
name: "Box Resource",
|
|
method: "get",
|
|
url: encodeDataSourceVariable(newDataSourceId),
|
|
headers: [
|
|
{
|
|
name: "authorization",
|
|
value: encodeDataSourceVariable(newDataSourceId),
|
|
},
|
|
],
|
|
searchParams: [
|
|
{
|
|
name: "query",
|
|
value: encodeDataSourceVariable(newDataSourceId),
|
|
},
|
|
],
|
|
},
|
|
])
|
|
);
|
|
expect($instances.get().get("fragment")?.children).toEqual([
|
|
{ type: "id", value: "div" },
|
|
]);
|
|
expect($instances.get().get("div")?.children).toEqual([
|
|
{ type: "id", value: "box" },
|
|
]);
|
|
});
|
|
|
|
test("cuts shared slot child from all slot occurrences", () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "fragment", "slot1", "body0"]);
|
|
|
|
const clipboardData = instanceText.onCut?.();
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instance/v0.1": {
|
|
instanceSelector: ["box", "fragment", "slot1", "body0"],
|
|
children: [{ type: "id", value: "box" }],
|
|
},
|
|
});
|
|
|
|
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
|
|
expect($instances.get().get("slot1")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("slot2")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("fragment")?.children).toEqual([]);
|
|
expect($instances.get().has("box")).toBe(false);
|
|
});
|
|
|
|
test("cuts legacy shared slot child from all slot occurrences", () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("slot2", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("heading", "Heading", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "slot1", "body0"]);
|
|
|
|
const clipboardData = instanceText.onCut?.();
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instance/v0.1": {
|
|
instanceSelector: ["box", "slot1", "body0"],
|
|
children: [{ type: "id", value: "box" }],
|
|
},
|
|
});
|
|
|
|
const fragmentId = expectSlotsShareFragment($instances.get(), [
|
|
"slot1",
|
|
"slot2",
|
|
]);
|
|
expect($instances.get().get("slot1")?.children).toEqual([
|
|
{ type: "id", value: fragmentId },
|
|
]);
|
|
expect($instances.get().get("slot2")?.children).toEqual([
|
|
{ type: "id", value: fragmentId },
|
|
]);
|
|
expect($instances.get().get(fragmentId ?? "")?.children).toEqual([
|
|
{ type: "id", value: "heading" },
|
|
]);
|
|
expect($instances.get().has("box")).toBe(false);
|
|
});
|
|
|
|
test("cuts nested shared slot child from all slot occurrences", () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
|
|
createInstance("div", "Box", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "div", "fragment", "slot1", "body0"]);
|
|
|
|
const clipboardData = instanceText.onCut?.();
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instance/v0.1": {
|
|
instanceSelector: ["box", "div", "fragment", "slot1", "body0"],
|
|
children: [{ type: "id", value: "box" }],
|
|
},
|
|
});
|
|
|
|
expect($instances.get().get("slot1")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("slot2")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("fragment")?.children).toEqual([
|
|
{ type: "id", value: "div" },
|
|
]);
|
|
expect($instances.get().get("div")?.children).toEqual([]);
|
|
expect($instances.get().has("box")).toBe(false);
|
|
});
|
|
|
|
test("cuts shared slot child and preserves shared siblings", () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("heading", "Heading", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "fragment", "slot1", "body0"]);
|
|
|
|
const clipboardData = instanceText.onCut?.();
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instance/v0.1": {
|
|
instanceSelector: ["box", "fragment", "slot1", "body0"],
|
|
children: [{ type: "id", value: "box" }],
|
|
},
|
|
});
|
|
|
|
expect($instances.get().get("slot1")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("slot2")?.children).toEqual([
|
|
{ type: "id", value: "fragment" },
|
|
]);
|
|
expect($instances.get().get("fragment")?.children).toEqual([
|
|
{ type: "id", value: "heading" },
|
|
]);
|
|
expect($instances.get().has("box")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("data sources", () => {
|
|
// body0
|
|
// box1
|
|
// box2
|
|
const instances: Instances = toMap([
|
|
createInstance("body0", "Body", [{ type: "id", value: "box1" }]),
|
|
createInstance("box1", "Box", [{ type: "id", value: "box2" }]),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[]);
|
|
const dataSources: DataSources = toMap([
|
|
{
|
|
id: "box1$state",
|
|
scopeInstanceId: "box1",
|
|
type: "variable",
|
|
name: "state",
|
|
value: { type: "string", value: "initial" },
|
|
},
|
|
] satisfies DataSource[]);
|
|
const props: Props = toMap([
|
|
{
|
|
id: "box1$stateProp",
|
|
instanceId: "box1",
|
|
name: "state",
|
|
type: "expression",
|
|
value: "$ws$dataSource$box1$state",
|
|
},
|
|
{
|
|
id: "box2$stateProp",
|
|
instanceId: "box2",
|
|
name: "state",
|
|
type: "expression",
|
|
value: "$ws$dataSource$box1$state",
|
|
},
|
|
{
|
|
id: "box2$showProp",
|
|
instanceId: "box2",
|
|
name: "show",
|
|
type: "expression",
|
|
value: `$ws$dataSource$box1$state === 'initial'`,
|
|
},
|
|
{
|
|
id: "box2$onChangeProp",
|
|
instanceId: "box2",
|
|
type: "action",
|
|
name: "onChange",
|
|
value: [
|
|
{
|
|
type: "execute",
|
|
args: ["value"],
|
|
code: `$ws$dataSource$box1$state = value`,
|
|
},
|
|
],
|
|
},
|
|
] satisfies Prop[]);
|
|
|
|
test("are copy pasted when scoped to copied instances", async () => {
|
|
$instances.set(instances);
|
|
$dataSources.set(dataSources);
|
|
$props.set(props);
|
|
selectInstance(["box1", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["body0"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const instancesDifference = getMapDifference(instances, $instances.get());
|
|
const [newBox1, newBox2] = instancesDifference.keys();
|
|
|
|
const dataSourcesDifference = getMapDifference(
|
|
dataSources,
|
|
$dataSources.get()
|
|
);
|
|
const [newDataSource1] = dataSourcesDifference.keys();
|
|
expect(dataSourcesDifference).toEqual(
|
|
toMap([
|
|
{
|
|
...dataSources.get("box1$state"),
|
|
id: newDataSource1,
|
|
scopeInstanceId: newBox1,
|
|
},
|
|
])
|
|
);
|
|
|
|
const propsDifference = getMapDifference(props, $props.get());
|
|
const [newProp1, newProp2, newProp3, newProp4] = propsDifference.keys();
|
|
expect(propsDifference).toEqual(
|
|
toMap([
|
|
{
|
|
id: newProp1,
|
|
instanceId: newBox1,
|
|
name: "state",
|
|
type: "expression",
|
|
value: encodeDataSourceVariable(newDataSource1),
|
|
},
|
|
{
|
|
id: newProp2,
|
|
instanceId: newBox2,
|
|
name: "state",
|
|
type: "expression",
|
|
value: encodeDataSourceVariable(newDataSource1),
|
|
},
|
|
{
|
|
id: newProp3,
|
|
instanceId: newBox2,
|
|
name: "show",
|
|
type: "expression",
|
|
value: `${encodeDataSourceVariable(newDataSource1)} === 'initial'`,
|
|
},
|
|
{
|
|
id: newProp4,
|
|
instanceId: newBox2,
|
|
name: "onChange",
|
|
type: "action",
|
|
value: [
|
|
{
|
|
type: "execute",
|
|
args: ["value"],
|
|
code: `${encodeDataSourceVariable(newDataSource1)} = value`,
|
|
},
|
|
],
|
|
},
|
|
])
|
|
);
|
|
});
|
|
|
|
test("preserve data sources outside of scope when pasted within their scope", async () => {
|
|
$instances.set(instances);
|
|
$props.set(props);
|
|
$dataSources.set(dataSources);
|
|
selectInstance(["box2", "box1", "body0"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["box1", "body0"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const instancesDifference = getMapDifference(instances, $instances.get());
|
|
const [newBox2] = instancesDifference.keys();
|
|
|
|
const dataSourcesDifference = getMapDifference(
|
|
dataSources,
|
|
$dataSources.get()
|
|
);
|
|
expect(dataSourcesDifference).toEqual(new Map());
|
|
|
|
const propsDifference = getMapDifference(props, $props.get());
|
|
const [newProp1, newProp2, newProp3] = propsDifference.keys();
|
|
expect(propsDifference).toEqual(
|
|
toMap([
|
|
{
|
|
id: newProp1,
|
|
instanceId: newBox2,
|
|
name: "state",
|
|
type: "expression",
|
|
value: `$ws$dataSource$box1$state`,
|
|
},
|
|
{
|
|
id: newProp2,
|
|
instanceId: newBox2,
|
|
name: "show",
|
|
type: "expression",
|
|
value: `$ws$dataSource$box1$state === 'initial'`,
|
|
},
|
|
{
|
|
id: newProp3,
|
|
instanceId: newBox2,
|
|
type: "action",
|
|
name: "onChange",
|
|
value: [
|
|
{
|
|
args: ["value"],
|
|
code: "$ws$dataSource$box1$state = value",
|
|
type: "execute",
|
|
},
|
|
],
|
|
},
|
|
])
|
|
);
|
|
});
|
|
|
|
test("copy parameter prop with new data source", async () => {
|
|
setPageRoot("body");
|
|
const instances: Instances = toMap([
|
|
createInstance("body", "Body", [{ type: "id", value: "list" }]),
|
|
createInstance("list", collectionComponent, []),
|
|
] satisfies Instance[]);
|
|
const dataSources: DataSources = toMap([
|
|
{
|
|
id: "itemDataSource",
|
|
scopeInstanceId: "list",
|
|
type: "parameter",
|
|
name: "item",
|
|
},
|
|
] satisfies DataSource[]);
|
|
const props: Props = toMap([
|
|
{
|
|
id: "dataProp",
|
|
instanceId: "list",
|
|
name: "data",
|
|
type: "json",
|
|
value: [],
|
|
},
|
|
{
|
|
id: "itemProp",
|
|
instanceId: "list",
|
|
name: "item",
|
|
type: "parameter",
|
|
value: "itemDataSource",
|
|
},
|
|
] satisfies Prop[]);
|
|
$instances.set(instances);
|
|
$props.set(props);
|
|
$dataSources.set(dataSources);
|
|
selectInstance(["list", "body"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["body"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const instancesDifference = getMapDifference(instances, $instances.get());
|
|
const [collectionId] = instancesDifference.keys();
|
|
|
|
const dataSourcesDifference = getMapDifference(
|
|
dataSources,
|
|
$dataSources.get()
|
|
);
|
|
const [itemDataSourceId] = dataSourcesDifference.keys();
|
|
expect(dataSourcesDifference).toEqual(
|
|
new Map([
|
|
[
|
|
itemDataSourceId,
|
|
{
|
|
id: itemDataSourceId,
|
|
scopeInstanceId: collectionId,
|
|
type: "parameter",
|
|
name: "item",
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
const propsDifference = getMapDifference(props, $props.get());
|
|
const [dataPropId, itemPropId] = propsDifference.keys();
|
|
expect(propsDifference).toEqual(
|
|
toMap([
|
|
{
|
|
id: dataPropId,
|
|
instanceId: collectionId,
|
|
name: "data",
|
|
type: "json",
|
|
value: [],
|
|
},
|
|
{
|
|
id: itemPropId,
|
|
instanceId: collectionId,
|
|
name: "item",
|
|
type: "parameter",
|
|
value: itemDataSourceId,
|
|
},
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
test("when paste into copied instance insert after it", async () => {
|
|
setPageRoot("body");
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body", "Body", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
])
|
|
);
|
|
selectInstance(["box", "body"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expect($instances.get()).toEqual(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: expectString },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance(expectString, "Box", []),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("prevent pasting portal into own descendants", async () => {
|
|
const instances = toMap([
|
|
createInstance("body", "Body", [{ type: "id", value: "portal" }]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
]);
|
|
$instances.set(instances);
|
|
selectInstance(["portal", "body"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["fragment", "portal", "body"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
expect($instances.get()).toEqual(instances);
|
|
});
|
|
|
|
test("prevent pasting portal into copy of it", async () => {
|
|
const instances = toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal1" },
|
|
{ type: "id", value: "portal2" },
|
|
]),
|
|
createInstance("portal1", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("portal2", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("fragment", "Fragment", []),
|
|
]);
|
|
$instances.set(instances);
|
|
selectInstance(["portal1", "body"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["portal2", "body"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
expect($instances.get()).toEqual(instances);
|
|
});
|
|
|
|
test("insert portal into its sibling", async () => {
|
|
setPageRoot("body");
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "sibling" },
|
|
]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("fragment", "Fragment", []),
|
|
createInstance("sibling", "Box", []),
|
|
])
|
|
);
|
|
selectInstance(["portal", "body"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["sibling", "body"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expect($instances.get()).toEqual(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "sibling" },
|
|
]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("fragment", "Fragment", []),
|
|
createInstance("sibling", "Box", [{ type: "id", value: expectString }]),
|
|
createInstance(expectString, portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("inserts multi-root clipboard into descendant of copied non-portal root", async () => {
|
|
setPageRoot("body");
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "box" },
|
|
]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("fragment", "Fragment", []),
|
|
createInstance("box", "Box", [{ type: "id", value: "target" }]),
|
|
createInstance("target", "Box", []),
|
|
])
|
|
);
|
|
selectInstances([
|
|
["portal", "body"],
|
|
["box", "body"],
|
|
]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["target", "box", "body"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const targetChildren = $instances.get().get("target")?.children;
|
|
const [pastedPortalId, pastedBoxId] =
|
|
targetChildren?.map((child) => (child.type === "id" ? child.value : "")) ??
|
|
[];
|
|
expect(targetChildren).toEqual([
|
|
{ type: "id", value: expectString },
|
|
{ type: "id", value: expectString },
|
|
]);
|
|
expect($instances.get().get(pastedPortalId ?? "")?.component).toBe(
|
|
portalComponent
|
|
);
|
|
expect($instances.get().get(pastedBoxId ?? "")?.component).toBe("Box");
|
|
});
|
|
|
|
test("insert into portal fragment when portal is a target", async () => {
|
|
setPageRoot("body");
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "box" },
|
|
]),
|
|
createInstance("portal", portalComponent, []),
|
|
createInstance("box", "Box", []),
|
|
])
|
|
);
|
|
selectInstance(["box", "body"]);
|
|
const clipboardData = instanceText.onCopy?.() ?? "";
|
|
selectInstance(["portal", "body"]);
|
|
|
|
// fragment not exists
|
|
const prevInstances = $instances.get();
|
|
await instanceText.onPaste?.(clipboardData);
|
|
const newInstances = getMapDifference(prevInstances, $instances.get());
|
|
const boxId = Array.from(newInstances.values()).find(
|
|
(instance) => instance.component === "Box"
|
|
)?.id;
|
|
const fragmentId = Array.from(newInstances.values()).find(
|
|
(instance) => instance.component === "Fragment"
|
|
)?.id;
|
|
if (boxId === undefined || fragmentId === undefined) {
|
|
throw Error("Expected pasted Box and Fragment instances");
|
|
}
|
|
expect($instances.get()).toEqual(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "box" },
|
|
]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: fragmentId },
|
|
]),
|
|
createInstance(fragmentId, "Fragment", [{ type: "id", value: boxId }]),
|
|
createInstance("box", "Box", []),
|
|
createInstance(boxId, "Box", []),
|
|
])
|
|
);
|
|
|
|
// fragment already exists
|
|
selectInstance(["portal", "body"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
expect($instances.get()).toEqual(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "box" },
|
|
]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: fragmentId },
|
|
]),
|
|
createInstance(fragmentId, "Fragment", [
|
|
{ type: "id", value: boxId },
|
|
{ type: "id", value: expectString },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance(boxId, "Box", []),
|
|
createInstance(expectString, "Box", []),
|
|
])
|
|
);
|
|
});
|