import { describe, test, expect, vi, beforeAll } from "vitest"; import { enableMapSet } from "immer"; import type { Instance, Instances, Prop, Props, DataSource, DataSources, } from "@webstudio-is/sdk"; import { encodeDataSourceVariable, collectionComponent, coreMetas, portalComponent, } from "@webstudio-is/sdk"; import type { Project } from "@webstudio-is/project"; import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas"; import { registerContainers } from "../sync/sync-stores"; import { $instances, $dataSources, $pages, $project, $props, $registeredComponentMetas, } from "../nano-states"; import { instanceText } from "./plugin-instance"; import { createDefaultPages } from "@webstudio-is/project-build"; import { $awareness, selectInstance } from "../awareness"; import * as instanceUtils from "../instance-utils"; const expectString = expect.any(String) as unknown as string; enableMapSet(); registerContainers(); // Mock detectFragmentTokenConflicts to always return no conflicts beforeAll(() => { vi.spyOn(instanceUtils, "detectFragmentTokenConflicts").mockReturnValue([]); }); $registeredComponentMetas.set( new Map(Object.entries({ ...baseComponentMetas, ...coreMetas })) ); $project.set({ id: "my-project" } as Project); $pages.set( createDefaultPages({ homePageId: "home-page", rootInstanceId: "body0", }) ); $awareness.set({ pageId: "home-page" }); const createInstance = ( id: Instance["id"], component: string, children: Instance["children"] ): Instance => { return { type: "instance", id, component, children }; }; const getIdValuePair = (item: T) => [item.id, item] as const; const toMap = (list: T[]) => new Map(list.map(getIdValuePair)); const getMapDifference = >( 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("paste target", () => { // 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", [])]) ); }); }); 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 () => { 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 () => { $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(["box", "body"]); const clipboardData = instanceText.onCopy?.() ?? ""; selectInstance(["portal", "body"]); await instanceText.onPaste?.(clipboardData); expect($instances.get()).toEqual(instances); }); test("insert portal into its sibling", async () => { $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("insert into portal fragment when portal is a target", async () => { $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 [boxId, fragmentId] = getMapDifference( prevInstances, $instances.get() ).keys(); 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 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", []), ]) ); });