import { enableMapSet } from "immer"; import { describe, test, expect, beforeEach } from "vitest"; import type { Project } from "@webstudio-is/project"; import { createDefaultPages } from "@webstudio-is/project-build"; import { $, ws, expression, renderTemplate, renderData, ResourceValue, } from "@webstudio-is/template"; import * as defaultMetas from "@webstudio-is/sdk-components-react/metas"; import * as radixMetas from "@webstudio-is/sdk-components-react-radix/metas"; import type { Asset, Breakpoint, Instance, StyleDecl, StyleDeclKey, StyleSource, WebstudioData, WebstudioFragment, WsComponentMeta, } from "@webstudio-is/sdk"; import { coreMetas, portalComponent, elementComponent, ROOT_INSTANCE_ID, } from "@webstudio-is/sdk"; import { showAttribute } from "@webstudio-is/react-sdk"; import type { StyleProperty, StyleValue } from "@webstudio-is/css-engine"; import { deleteInstanceMutable, extractWebstudioFragment, insertWebstudioFragmentCopy, reparentInstanceMutable, getWebstudioData, insertInstanceChildrenMutable, findClosestInsertable, insertWebstudioFragmentAt, insertWebstudioElementAt, buildInstancePath, wrapInstance, toggleInstanceShow, unwrapInstanceMutable, canUnwrapInstance, canConvertInstance, convertInstance, deleteSelectedInstance, } from "./instance-utils"; import type { InstancePath } from "./awareness"; import { $assets, $breakpoints, $dataSources, $instances, $pages, $project, $props, $styleSourceSelections, $styleSources, $styles, $registeredComponentMetas, $resources, } from "./nano-states"; import { registerContainers } from "./sync/sync-stores"; import { $awareness, getInstancePath, selectInstance, selectPage, } from "./awareness"; enableMapSet(); registerContainers(); $pages.set(createDefaultPages({ rootInstanceId: "" })); const defaultMetasMap = new Map( Object.entries({ ...defaultMetas, ...coreMetas }) ); $registeredComponentMetas.set(defaultMetasMap); const createFragment = ( fragment: Partial ): WebstudioFragment => ({ children: [], instances: [], styleSourceSelections: [], styleSources: [], breakpoints: [], styles: [], dataSources: [], resources: [], props: [], assets: [], ...fragment, }); const createFakeComponentMetas = ( itemMeta: Partial, anotherItemMeta?: Partial ): Map => { const base = { label: "", Icon: () => null, }; const configs = { Item: { ...base, ...itemMeta }, AnotherItem: { ...base, ...anotherItemMeta }, Bold: base, Text: base, Form: base, Box: base, Div: base, Body: base, } as const; return new Map(Object.entries(configs)) as Map; }; const getIdValuePair = (item: T) => [item.id, item] as const; const toMap = (list: T[]) => new Map(list.map(getIdValuePair)); const createInstance = ( id: Instance["id"], component: string, children: Instance["children"] ): Instance => { return { type: "instance", id, component, children }; }; const createStyleDecl = ( styleSourceId: string, breakpointId: string, property: StyleProperty, value: StyleValue | string ): StyleDecl => ({ styleSourceId, breakpointId, property, value: typeof value === "string" ? { type: "unparsed", value } : value, }); const createStyleDeclPair = ( styleSourceId: string, breakpointId: string, property: StyleProperty, value: StyleValue | string ): [StyleDeclKey, StyleDecl] => [ `${styleSourceId}:${breakpointId}:${property}:`, createStyleDecl(styleSourceId, breakpointId, property, value), ]; const createImageAsset = (id: string, name = "", projectId = ""): Asset => { return { id, type: "image", format: "", name, description: "", projectId, createdAt: "", size: 0, meta: { width: 0, height: 0 }, }; }; const createFontAsset = (id: string, family: string): Asset => { return { id, type: "font", format: "woff", name: "", description: "", projectId: "", createdAt: "", size: 0, meta: { style: "normal", family, variationAxes: {} }, }; }; describe("insert instance children", () => { test("insert instance children into empty target", () => { const data = renderData( ); const [div] = renderTemplate( ).instances; data.instances.set(div.id, div); insertInstanceChildrenMutable(data, [{ type: "id", value: "divId" }], { parentSelector: ["bodyId"], position: "end", }); expect(data).toEqual( renderData( ) ); }); test("insert instance children into the end of target", () => { const data = renderData( ); const [div] = renderTemplate( ).instances; data.instances.set(div.id, div); insertInstanceChildrenMutable(data, [{ type: "id", value: "divId" }], { parentSelector: ["bodyId"], position: "end", }); expect(data).toEqual( renderData( ) ); }); test("insert instance children into the start of target", () => { const data = renderData( ); const [div] = renderTemplate( ).instances; data.instances.set(div.id, div); insertInstanceChildrenMutable(data, [{ type: "id", value: "divId" }], { parentSelector: ["bodyId"], position: 0, }); expect(data).toEqual( renderData( ) ); }); test("insert instance children at the start of text", () => { const data = renderData( text ); const [div] = renderTemplate( ).instances; data.instances.set(div.id, div); insertInstanceChildrenMutable(data, [{ type: "id", value: "divId" }], { parentSelector: ["textId", "bodyId"], position: 0, }); const [_bodyId, _textId, _divId, spanId] = data.instances.keys(); expect(data).toEqual( renderData( text ) ); }); test("insert instance children at the end of text", () => { const data = renderData( text ); const [div] = renderTemplate( ).instances; data.instances.set(div.id, div); insertInstanceChildrenMutable(data, [{ type: "id", value: "divId" }], { parentSelector: ["textId", "bodyId"], position: "end", }); const [_bodyId, _textId, _divId, spanId] = data.instances.keys(); expect(data).toEqual( renderData( text ) ); }); test("insert instance children between text children", () => { const data = renderData( strong text emphasis ); const [div] = renderTemplate( ).instances; data.instances.set(div.id, div); insertInstanceChildrenMutable(data, [{ type: "id", value: "divId" }], { parentSelector: ["textId", "bodyId"], position: 1, }); const [ _bodyId, _textId, _strongId, _emId, _divId, leftSpanId, rightSpanId, ] = data.instances.keys(); expect(data).toEqual( renderData( strong text emphasis ) ); }); }); describe("insert webstudio element at", () => { beforeEach(() => { $styleSourceSelections.set(new Map()); $styleSources.set(new Map()); $breakpoints.set(new Map()); $styles.set(new Map()); $dataSources.set(new Map()); $resources.set(new Map()); $props.set(new Map()); $assets.set(new Map()); }); test("insert element with div tag into body", () => { $instances.set(renderData(<$.Body ws:id="bodyId">).instances); insertWebstudioElementAt({ parentSelector: ["bodyId"], position: "end", }); const [_bodyId, newInstanceId] = $instances.get().keys(); expect($instances.get()).toEqual( renderData( <$.Body ws:id="bodyId"> ).instances ); }); test("insert element with li tag into ul", () => { $instances.set( renderData( <$.Body ws:id="bodyId"> ).instances ); insertWebstudioElementAt({ parentSelector: ["listId", "bodyId"], position: "end", }); const [_bodyId, _listId, newInstanceId] = $instances.get().keys(); expect($instances.get()).toEqual( renderData( <$.Body ws:id="bodyId"> ).instances ); }); test("insert element into selected instance", () => { $pages.set( createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" }) ); $instances.set( renderData( <$.Body ws:id="bodyId"> ).instances ); selectPage("homePageId"); selectInstance(["divId", "bodyId"]); insertWebstudioElementAt(); const [_bodyId, _divId, newInstanceId] = $instances.get().keys(); expect($instances.get()).toEqual( renderData( <$.Body ws:id="bodyId"> ).instances ); }); test("insert element into closest non-textual container", () => { $pages.set( createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" }) ); $instances.set( renderData( <$.Body ws:id="bodyId"> text ).instances ); selectPage("homePageId"); selectInstance(["divId", "bodyId"]); insertWebstudioElementAt(); const [_bodyId, _divId, _spanId, newInstanceId] = $instances.get().keys(); expect($instances.get()).toEqual( renderData( <$.Body ws:id="bodyId"> text ).instances ); }); test("insert element into closest non-empty container", () => { $pages.set( createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" }) ); $instances.set( renderData( <$.Body ws:id="bodyId"> ).instances ); selectPage("homePageId"); selectInstance(["imgId", "bodyId"]); insertWebstudioElementAt(); const [_bodyId, _imgId, _spanId, newInstanceId] = $instances.get().keys(); expect($instances.get()).toEqual( renderData( <$.Body ws:id="bodyId"> ).instances ); }); }); describe("insert webstudio fragment at", () => { beforeEach(() => { $styleSourceSelections.set(new Map()); $styleSources.set(new Map()); $breakpoints.set(new Map()); $styles.set(new Map()); $dataSources.set(new Map()); $resources.set(new Map()); $props.set(new Map()); $assets.set(new Map()); }); test("insert multiple instances", () => { $instances.set(renderData(<$.Body ws:id="bodyId">).instances); insertWebstudioFragmentAt( renderTemplate( <> <$.Heading ws:id="headingId"> <$.Paragraph ws:id="paragraphId"> ), { parentSelector: ["bodyId"], position: "end", } ); expect($instances.get()).toEqual( renderData( <$.Body ws:id="bodyId"> <$.Heading ws:id={expect.any(String)}> <$.Paragraph ws:id={expect.any(String)}> ).instances ); }); test("insert fragment after insertable", () => { $instances.set( renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="boxId"> ).instances ); insertWebstudioFragmentAt( renderTemplate(<$.Heading ws:id="headingId">), { parentSelector: ["boxId", "bodyId"], position: "after", } ); expect($instances.get()).toEqual( renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="boxId"> <$.Heading ws:id={expect.any(String)}> ).instances ); }); test("insert fragment inside of body when configured to place after insertable", () => { $instances.set(renderData(<$.Body ws:id="bodyId">).instances); insertWebstudioFragmentAt( renderTemplate(<$.Heading ws:id="headingId">), { parentSelector: ["bodyId"], position: "after", } ); expect($instances.get()).toEqual( renderData( <$.Body ws:id="bodyId"> <$.Heading ws:id={expect.any(String)}> ).instances ); }); }); describe("reparent instance", () => { test("between instances", () => { const data = renderData( <$.Body ws:id="body"> <$.Box ws:id="box"> <$.Text ws:id="text"> <$.Button ws:id="button"> ); $registeredComponentMetas.set(createFakeComponentMetas({})); reparentInstanceMutable(data, ["text", "box", "body"], { parentSelector: ["body"], position: 1, }); const newTextId = data.instances.get("body")?.children[1].value as string; expect(data.instances).toEqual( renderData( <$.Body ws:id="body"> <$.Box ws:id="box"> <$.Text ws:id={newTextId}> <$.Button ws:id="button"> ).instances ); }); test("to the end", () => { const data = renderData( <$.Body ws:id="body"> <$.Box ws:id="box"> <$.Text ws:id="text"> ); $registeredComponentMetas.set(createFakeComponentMetas({})); reparentInstanceMutable(data, ["text", "box", "body"], { parentSelector: ["body"], position: "end", }); const newTextId = data.instances.get("body")?.children[1].value as string; expect(data.instances).toEqual( renderData( <$.Body ws:id="body"> <$.Box ws:id="box"> <$.Text ws:id={newTextId}> ).instances ); }); test("before itself", () => { const data = renderData( <$.Body ws:id="body"> <$.Text ws:id="text"> <$.Box ws:id="box"> <$.Button ws:id="button"> ); $registeredComponentMetas.set(createFakeComponentMetas({})); reparentInstanceMutable(data, ["box", "body"], { parentSelector: ["body"], position: 1, }); expect(data.instances).toEqual( renderData( <$.Body ws:id="body"> <$.Text ws:id="text"> <$.Box ws:id="box"> <$.Button ws:id="button"> ).instances ); }); test("after itself", () => { const data = renderData( <$.Body ws:id="body"> <$.Text ws:id="text"> <$.Box ws:id="box"> <$.Button ws:id="button"> ); $registeredComponentMetas.set(createFakeComponentMetas({})); reparentInstanceMutable(data, ["box", "body"], { parentSelector: ["body"], position: 2, }); expect(data.instances).toEqual( renderData( <$.Body ws:id="body"> <$.Text ws:id="text"> <$.Box ws:id="box"> <$.Button ws:id="button"> ).instances ); }); test("wrap with fragment when reparent into empty slot", () => { const data = renderData( <$.Body ws:id="body"> <$.Slot ws:id="slot"> <$.Box ws:id="box"> ); $registeredComponentMetas.set(createFakeComponentMetas({})); reparentInstanceMutable(data, ["box", "body"], { parentSelector: ["slot", "body"], position: "end", }); const newFragmentId = data.instances.get("slot")?.children[0] .value as string; const newBoxId = data.instances.get(newFragmentId)?.children[0] .value as string; expect(data.instances).toEqual( renderData( <$.Body ws:id="body"> <$.Slot ws:id="slot"> <$.Fragment ws:id={newFragmentId}> <$.Box ws:id={newBoxId}> ).instances ); }); test("reuse existing fragment when reparent into slot", () => { const data = renderData( <$.Body ws:id="body"> <$.Slot ws:id="slot"> <$.Fragment ws:id="fragment"> <$.Box ws:id="box"> ); $registeredComponentMetas.set(createFakeComponentMetas({})); reparentInstanceMutable(data, ["box", "body"], { parentSelector: ["slot", "body"], position: "end", }); const newBoxId = data.instances.get("fragment")?.children[0] .value as string; expect(data.instances).toEqual( renderData( <$.Body ws:id="body"> <$.Slot ws:id="slot"> <$.Fragment ws:id="fragment"> <$.Box ws:id={newBoxId}> ).instances ); }); test("reparent slot child from one instance of this slot into another", () => { const data = renderData( <$.Body ws:id="body"> <$.Slot ws:id="slot1"> <$.Fragment ws:id="fragment"> <$.Box ws:id="box"> <$.Slot ws:id="slot2"> {/* same ids */} <$.Fragment ws:id="fragment"> <$.Box ws:id="box"> ); $registeredComponentMetas.set(createFakeComponentMetas({})); reparentInstanceMutable(data, ["box", "fragment", "slot1", "body"], { parentSelector: ["slot2", "body"], position: "end", }); expect(data.instances).toEqual( renderData( <$.Body ws:id="body"> <$.Slot ws:id="slot1"> <$.Fragment ws:id="fragment"> <$.Box ws:id="box"> <$.Slot ws:id="slot2"> {/* same ids */} <$.Fragment ws:id="fragment"> <$.Box ws:id="box"> ).instances ); }); test("prevent slot reparenting into own children to avoid infinite loop", () => { const data = renderData( <$.Body ws:id="body"> <$.Slot ws:id="slot"> <$.Fragment ws:id="fragment"> ); $registeredComponentMetas.set(createFakeComponentMetas({})); reparentInstanceMutable(data, ["slot", "body"], { parentSelector: ["fragment", "slot", "body"], position: "end", }); expect(data.instances).toEqual( renderData( <$.Body ws:id="body"> <$.Slot ws:id="slot"> <$.Fragment ws:id="fragment"> ).instances ); }); test("from collection item", () => { const data = renderData( <$.Body ws:id="body"> <$.Box ws:id="box"> ); $registeredComponentMetas.set(createFakeComponentMetas({})); reparentInstanceMutable( data, ["box", "collection[0]", "collection", "body"], { parentSelector: ["body"], position: "end" } ); const newBoxId = data.instances.get("body")?.children[1].value as string; expect(data.instances).toEqual( renderData( <$.Body ws:id="body"> <$.Box ws:id={newBoxId}> ).instances ); }); test("move required child within same instance", () => { const data = renderData( <$.Body ws:id="body"> <$.Tooltip ws:id="tooltip"> <$.TooltipTrigger ws:id="trigger"> <$.TooltipContent ws:id="content"> ); $registeredComponentMetas.set( new Map(Object.entries({ ...defaultMetas, ...radixMetas })) ); reparentInstanceMutable(data, ["trigger", "tooltip", "body"], { parentSelector: ["tooltip", "body"], position: "end", }); expect(data.instances).toEqual( renderData( <$.Body ws:id="body"> <$.Tooltip ws:id="tooltip"> <$.TooltipContent ws:id="content"> <$.TooltipTrigger ws:id={expect.any(String)}> ).instances ); }); }); const getWebstudioDataStub = ( data?: Partial ): WebstudioData => ({ pages: createDefaultPages({ rootInstanceId: "" }), assets: new Map(), dataSources: new Map(), resources: new Map(), instances: new Map(), props: new Map(), breakpoints: new Map(), styleSourceSelections: new Map(), styleSources: new Map(), styles: new Map(), ...data, }); describe("delete instance", () => { test("delete instance with its children", () => { const data = renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="boxId"> <$.Box ws:id="sectionId"> <$.Box ws:id="divId"> ); expect(data.instances.size).toEqual(4); expect(data.instances.get("bodyId")?.children.length).toEqual(2); deleteInstanceMutable( data, // clone to make sure data is mutated instead of instance path structuredClone(getInstancePath(["boxId", "bodyId"], data.instances)) ); expect(data.instances.size).toEqual(2); expect(data.instances.get("bodyId")?.children.length).toEqual(1); }); test("delete instance from collection", () => { const data = renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="boxId"> ); expect(data.instances.size).toEqual(3); expect(data.instances.get("collectionId")?.children.length).toEqual(1); deleteInstanceMutable( data, getInstancePath( ["boxId", "collectionId[0]", "collectionId", "bodyId"], data.instances ) ); expect(data.instances.size).toEqual(2); expect(data.instances.get("collectionId")?.children.length).toEqual(0); }); test("delete instance from collection item", () => { const data = renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="boxId"> <$.Text ws:id="textId"> ); expect(data.instances.size).toEqual(4); expect(data.instances.get("boxId")?.children.length).toEqual(1); deleteInstanceMutable( data, getInstancePath( ["textId", "boxId", "collectionId[0]", "collectionId", "bodyId"], data.instances ) ); expect(data.instances.size).toEqual(3); expect(data.instances.get("boxId")?.children.length).toEqual(0); }); test("delete resource bound to variable", () => { const myResource = new ResourceValue("My Resource", { url: expression`""`, method: "get", searchParams: [], headers: [], }); const data = renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="boxId" vars={expression`${myResource}`}> ); expect(data.resources.size).toEqual(1); expect(data.dataSources.size).toEqual(1); deleteInstanceMutable( data, getInstancePath(["boxId", "bodyId"], data.instances) ); expect(data.resources.size).toEqual(0); expect(data.dataSources.size).toEqual(0); }); test("delete resource bound to prop", () => { const myResource = new ResourceValue("My Resource", { url: expression`""`, method: "get", searchParams: [], headers: [], }); const data = renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="boxId" action={myResource}> ); expect(data.resources.size).toEqual(1); expect(data.props.size).toEqual(1); deleteInstanceMutable( data, getInstancePath(["boxId", "bodyId"], data.instances) ); expect(data.resources.size).toEqual(0); expect(data.props.size).toEqual(0); }); test("delete unknown instance (just in case)", () => { const data = renderData( <$.Body ws:id="bodyId"> <$.Invalid ws:id="invalidId"> ); expect(data.instances.size).toEqual(2); deleteInstanceMutable( data, getInstancePath(["invalidId", "bodyId"], data.instances) ); expect(data.instances.size).toEqual(1); }); test("delete slot fragment along with last child", () => { const data = renderData( <$.Body ws:id="bodyId"> <$.Slot ws:id="slotId"> <$.Fragment ws:id="fragmentId"> <$.Box ws:id="boxId"> ); expect(data.instances.size).toEqual(4); expect(data.instances.get("fragmentId")?.children.length).toEqual(1); deleteInstanceMutable( data, getInstancePath( ["boxId", "fragmentId", "slotId", "bodyId"], data.instances ) ); expect(data.instances.size).toEqual(2); expect(data.instances.get("slotId")?.children.length).toEqual(0); }); }); describe("wrap in", () => { test("wrap instance in link", () => { $instances.set( renderData( ).instances ); selectInstance(["divId", "bodyId"]); wrapInstance(elementComponent, "a"); expect($instances.get()).toEqual( renderData( ).instances ); }); test("wrap image in element", () => { $instances.set( renderData( ).instances ); selectInstance(["imageId", "bodyId"]); wrapInstance(elementComponent); expect($instances.get()).toEqual( renderData( ).instances ); }); test("avoid wrapping text with link in link", () => { const { instances } = renderData( ); $instances.set(instances); selectInstance(["textId", "bodyId"]); wrapInstance(elementComponent, "a"); // nothing is changed expect($instances.get()).toEqual(instances); }); test("avoid wrapping textual content", () => { const { instances } = renderData( ); $instances.set(instances); selectInstance(["boldId", "textId", "bodyId"]); wrapInstance(elementComponent); // nothing is changed expect($instances.get()).toEqual(instances); }); test("avoid wrapping list item", () => { const { instances } = renderData( ); $instances.set(instances); selectInstance(["listItemId", "listId", "bodyId"]); wrapInstance(elementComponent); // nothing is changed expect($instances.get()).toEqual(instances); }); }); describe("extract webstudio fragment", () => { test("collect all styles and breakpoints bound to fragment instances", () => { // body // box1 // box2 $instances.set( toMap([ createInstance("body", "Body", [{ type: "id", value: "box1" }]), createInstance("box1", "Box", [{ type: "id", value: "box2" }]), createInstance("box2", "Box", []), ]) ); $styleSourceSelections.set( new Map([ ["body", { instanceId: "box1", values: ["localBody", "token1"] }], ["box1", { instanceId: "box1", values: ["localBox1", "token2"] }], ["box2", { instanceId: "box2", values: ["localBox2", "token2"] }], ]) ); $styleSources.set( new Map([ ["localBody", { id: "localBody", type: "local" }], ["localBox1", { id: "localBox1", type: "local" }], ["localBox2", { id: "localBox2", type: "local" }], ["token1", { id: "token1", type: "token", name: "token1" }], ["token2", { id: "token2", type: "token", name: "token2" }], ]) ); $styles.set( new Map([ createStyleDeclPair("localBody1", "base", "color", "red"), createStyleDeclPair("localBox1", "base", "color", "green"), createStyleDeclPair("localBox2", "base", "color", "blue"), createStyleDeclPair("token1", "base", "color", "yellow"), createStyleDeclPair("token2", "base", "color", "orange"), ]) ); $breakpoints.set( new Map([ ["base", { id: "base", label: "base" }], ["big", { id: "big", label: "big", minWidth: 768 }], ]) ); const { styleSources, styleSourceSelections, styles, breakpoints } = extractWebstudioFragment(getWebstudioData(), "box1"); // exclude localBody and token1 bound to body expect(styleSources).toEqual([ { id: "localBox1", type: "local" }, { id: "token2", type: "token", name: "token2" }, { id: "localBox2", type: "local" }, ]); expect(styleSourceSelections).toEqual([ { instanceId: "box1", values: ["localBox1", "token2"] }, { instanceId: "box2", values: ["localBox2", "token2"] }, ]); expect(styles).toEqual([ createStyleDecl("localBox1", "base", "color", "green"), createStyleDecl("localBox2", "base", "color", "blue"), createStyleDecl("token2", "base", "color", "orange"), ]); expect(breakpoints).toEqual([{ id: "base", label: "base" }]); }); test("collect assets from props and styles withiin fragment instances", () => { // body // box1 // box2 $instances.set( toMap([ createInstance("body", "Body", [{ type: "id", value: "box1" }]), createInstance("box1", "Box", [{ type: "id", value: "box2" }]), createInstance("box2", "Box", []), ]) ); $props.set( new Map([ [ "bodyProp", { id: "bodyProp", instanceId: "body", name: "data-body", type: "asset", value: "asset1", }, ], [ "box1Prop", { id: "box1Prop", instanceId: "box1", name: "data-box1", type: "asset", value: "asset2", }, ], ]) ); $styleSourceSelections.set( new Map([ ["body", { instanceId: "box1", values: ["localBody"] }], ["box1", { instanceId: "box1", values: ["localBox1"] }], ]) ); $styleSources.set( new Map([ ["localBody", { id: "localBody", type: "local" }], ["localBox1", { id: "localBox1", type: "local" }], ]) ); $styles.set( new Map([ createStyleDeclPair("localBody1", "base", "fontFamily", { type: "fontFamily", value: ["font1"], }), createStyleDeclPair("localBody1", "base", "backgroundImage", { type: "image", value: { type: "asset", value: "asset3" }, }), createStyleDeclPair("localBox1", "base", "fontFamily", { type: "fontFamily", value: ["font2"], }), createStyleDeclPair("localBox1", "base", "color", { type: "image", value: { type: "asset", value: "asset4" }, }), ]) ); $breakpoints.set(new Map([["base", { id: "base", label: "base" }]])); $assets.set( toMap([ createImageAsset("asset1"), createImageAsset("asset2"), createImageAsset("asset3"), createImageAsset("asset4"), createFontAsset("asset5", "font1"), createFontAsset("asset6", "font2"), ]) ); const { assets } = extractWebstudioFragment(getWebstudioData(), "box1"); expect(assets).toEqual([ createImageAsset("asset2"), createImageAsset("asset4"), createFontAsset("asset6", "font2"), ]); }); }); describe("insert webstudio fragment copy", () => { const emptyFragment = { children: [], instances: [ { id: "body", type: "instance", component: "Body", children: [], } satisfies Instance, ], styleSourceSelections: [], styleSources: [], breakpoints: [], styles: [], dataSources: [], resources: [], props: [], assets: [], }; $project.set({ id: "current_project" } as Project); beforeEach(() => { $assets.set(new Map()); $breakpoints.set(new Map()); $styleSourceSelections.set(new Map()); $styleSources.set(new Map()); $styles.set(new Map()); $instances.set(new Map()); $props.set(new Map()); $dataSources.set(new Map()); }); test("insert assets with same ids if missing", () => { const data = getWebstudioDataStub(); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, assets: [createImageAsset("asset1", "name", "another_project")], }, availableVariables: [], projectId: "current_project", }); expect(Array.from(data.assets.values())).toEqual([ createImageAsset("asset1", "name", "current_project"), ]); data.assets = toMap([ createImageAsset("asset1", "changed_name", "current_project"), ]); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, assets: [ createImageAsset("asset1", "name", "another_project"), createImageAsset("asset2", "another_name", "another_project"), ], }, availableVariables: [], projectId: "current_project", }); expect(Array.from(data.assets.values())).toEqual([ // preserve any user changes createImageAsset("asset1", "changed_name", "current_project"), // add new assets while preserving old ones createImageAsset("asset2", "another_name", "current_project"), ]); }); test("merge breakpoints with existing ones", () => { const breakpoints = toMap([ { id: "existing_base", label: "base" }, { id: "existing_small", label: "small", minWidth: 768 }, ]); const data = getWebstudioDataStub({ breakpoints }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, breakpoints: [ { id: "new_base", label: "Base" }, { id: "new_small", label: "Small", minWidth: 768, }, { id: "new_large", label: "Large", minWidth: 1200, }, ], }, availableVariables: [], projectId: "", }); expect(Array.from(data.breakpoints.values())).toEqual([ { id: "existing_base", label: "base" }, { id: "existing_small", label: "small", minWidth: 768 }, { id: "new_large", label: "Large", minWidth: 1200 }, ]); }); // Case 2: Same styles AND same name -> reuse existing token test("token with same styles and same name reuses existing token", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "existingToken", type: "token", name: "primaryColor" }, ]); const styles = new Map([ createStyleDeclPair("existingToken", "base", "color", { type: "keyword", value: "red", }), createStyleDeclPair("existingToken", "base", "fontSize", { type: "unit", value: 16, unit: "px", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styles }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, breakpoints: [{ id: "base", label: "base" }], styleSources: [ // Same name "primaryColor", same styles, different id { id: "newToken", type: "token", name: "primaryColor" }, ], styles: [ { styleSourceId: "newToken", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, { styleSourceId: "newToken", breakpointId: "base", property: "fontSize", value: { type: "unit", value: 16, unit: "px" }, }, ], }, availableVariables: [], projectId: "", }); // Should reuse existing token, not create a new one expect(Array.from(data.styleSources.values())).toEqual([ { id: "existingToken", type: "token", name: "primaryColor" }, ]); // No new styles should be added expect(Array.from(data.styles.values())).toEqual([ { styleSourceId: "existingToken", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, { styleSourceId: "existingToken", breakpointId: "base", property: "fontSize", value: { type: "unit", value: 16, unit: "px" }, }, ]); }); // Case 3: Same styles but different name -> insert new token with original name test("token with same styles but different name inserts new token", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "token1", type: "token", name: "primaryColor" }, ]); const styles = new Map([ createStyleDeclPair("token1", "base", "color", { type: "keyword", value: "red", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styles }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, breakpoints: [{ id: "base", label: "base" }], styleSources: [ // Different name "accentColor", same styles { id: "token2", type: "token", name: "accentColor" }, ], styles: [ { styleSourceId: "token2", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ], }, availableVariables: [], projectId: "", }); // Should insert new token with its original name const tokens = Array.from(data.styleSources.values()); expect(tokens).toHaveLength(2); expect(tokens[0]).toEqual({ id: "token1", type: "token", name: "primaryColor", }); expect(tokens[1]).toMatchObject({ type: "token", name: "accentColor" }); expect(tokens[1].id).not.toBe("token2"); // Should have new ID const tokenStyles = Array.from(data.styles.values()); expect(tokenStyles).toHaveLength(2); expect(tokenStyles[0]).toEqual({ styleSourceId: "token1", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }); expect(tokenStyles[1]).toMatchObject({ styleSourceId: tokens[1].id, // Should reference new token ID breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }); }); // Case 4: Different styles but same name -> add counter suffix test("token with different styles but same name adds counter suffix", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "token1", type: "token", name: "myToken" }, ]); const styles = new Map([ createStyleDeclPair("token1", "base", "color", { type: "keyword", value: "blue", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styles }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, breakpoints: [{ id: "base", label: "base" }], styleSources: [ // Same name "myToken", different styles { id: "token2", type: "token", name: "myToken" }, ], styles: [ { styleSourceId: "token2", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ], }, availableVariables: [], projectId: "", }); // Should add counter suffix to the new token const tokens = Array.from(data.styleSources.values()); expect(tokens).toHaveLength(2); expect(tokens[0]).toEqual({ id: "token1", type: "token", name: "myToken" }); expect(tokens[1]).toMatchObject({ type: "token", name: "myToken-1" }); expect(tokens[1].id).not.toBe("token2"); // Should have new ID const tokenStyles = Array.from(data.styles.values()); expect(tokenStyles).toHaveLength(2); expect(tokenStyles[0]).toEqual({ styleSourceId: "token1", breakpointId: "base", property: "color", value: { type: "keyword", value: "blue" }, }); expect(tokenStyles[1]).toMatchObject({ styleSourceId: tokens[1].id, breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }); }); // Case 4b: Multiple counter suffixes test("token with name conflict increments counter correctly", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "token1", type: "token", name: "myToken" }, { id: "token2", type: "token", name: "myToken-1" }, { id: "token3", type: "token", name: "myToken-2" }, ]); const styles = new Map([ createStyleDeclPair("token1", "base", "color", { type: "keyword", value: "blue", }), createStyleDeclPair("token2", "base", "color", { type: "keyword", value: "green", }), createStyleDeclPair("token3", "base", "color", { type: "keyword", value: "yellow", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styles }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, breakpoints: [{ id: "base", label: "base" }], styleSources: [{ id: "token4", type: "token", name: "myToken" }], styles: [ { styleSourceId: "token4", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ], }, availableVariables: [], projectId: "", }); // Should use counter 3 const tokens = Array.from(data.styleSources.values()); expect(tokens).toHaveLength(4); expect(tokens[0]).toEqual({ id: "token1", type: "token", name: "myToken" }); expect(tokens[1]).toEqual({ id: "token2", type: "token", name: "myToken-1", }); expect(tokens[2]).toEqual({ id: "token3", type: "token", name: "myToken-2", }); expect(tokens[3]).toMatchObject({ type: "token", name: "myToken-3" }); expect(tokens[3].id).not.toBe("token4"); // Should have new ID }); // Case 6: Different styles and different name -> insert as-is test("token with different styles and different name inserts normally", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "token1", type: "token", name: "primaryColor" }, ]); const styles = new Map([ createStyleDeclPair("token1", "base", "color", { type: "keyword", value: "blue", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styles }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, breakpoints: [{ id: "base", label: "base" }], styleSources: [{ id: "token2", type: "token", name: "secondaryColor" }], styles: [ { styleSourceId: "token2", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ], }, availableVariables: [], projectId: "", }); // Should insert new token normally const tokens = Array.from(data.styleSources.values()); expect(tokens).toHaveLength(2); expect(tokens[0]).toEqual({ id: "token1", type: "token", name: "primaryColor", }); expect(tokens[1]).toMatchObject({ type: "token", name: "secondaryColor" }); expect(tokens[1].id).not.toBe("token2"); // Should have new ID const tokenStyles = Array.from(data.styles.values()); expect(tokenStyles).toHaveLength(2); expect(tokenStyles[0]).toEqual({ styleSourceId: "token1", breakpointId: "base", property: "color", value: { type: "keyword", value: "blue" }, }); expect(tokenStyles[1]).toMatchObject({ styleSourceId: tokens[1].id, breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }); }); // Test that instance with matching token gets the token reference updated test("instance with reused token updates styleSourceSelection to existing token id", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "existingToken", type: "token", name: "primaryColor" }, ]); const styles = new Map([ createStyleDeclPair("existingToken", "base", "color", { type: "keyword", value: "red", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styles }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, instances: [ { type: "instance", id: "box", component: "Box", children: [], }, ], breakpoints: [{ id: "base", label: "base" }], styleSources: [ // Same name and same styles as existingToken { id: "newToken", type: "token", name: "primaryColor" }, ], styleSourceSelections: [{ instanceId: "box", values: ["newToken"] }], styles: [ { styleSourceId: "newToken", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ], }, availableVariables: [], projectId: "", }); // Should reuse existing token expect(Array.from(data.styleSources.values())).toEqual([ { id: "existingToken", type: "token", name: "primaryColor" }, ]); // The instance should reference the existing token, not the new one const newBoxId = Array.from(data.instances.keys())[0]; expect(data.styleSourceSelections.get(newBoxId)).toEqual({ instanceId: newBoxId, values: ["existingToken"], // Should use existing token id, not "newToken" }); }); // Case 3 safeguard: Same styles but different name gets suffix when name conflicts test("token with same styles but different name adds suffix when name already exists", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "token1", type: "token", name: "primaryColor" }, { id: "token2", type: "token", name: "accentColor" }, // This name is taken ]); const styles = new Map([ createStyleDeclPair("token1", "base", "color", { type: "keyword", value: "red", }), createStyleDeclPair("token2", "base", "fontSize", { type: "unit", value: 16, unit: "px", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styles }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, breakpoints: [{ id: "base", label: "base" }], styleSources: [ // Same styles as token1, but wants to use name "accentColor" which is already taken { id: "token3", type: "token", name: "accentColor" }, ], styles: [ { styleSourceId: "token3", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ], }, availableVariables: [], projectId: "", }); // Should add counter suffix to prevent duplicate name const tokens = Array.from(data.styleSources.values()); expect(tokens).toHaveLength(3); expect(tokens[0]).toEqual({ id: "token1", type: "token", name: "primaryColor", }); expect(tokens[1]).toEqual({ id: "token2", type: "token", name: "accentColor", }); expect(tokens[2]).toMatchObject({ type: "token", name: "accentColor-1" }); expect(tokens[2].id).not.toBe("token3"); // Should have new ID const tokenStyles = Array.from(data.styles.values()); expect(tokenStyles).toHaveLength(3); expect(tokenStyles[0]).toEqual({ styleSourceId: "token1", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }); expect(tokenStyles[1]).toEqual({ styleSourceId: "token2", breakpointId: "base", property: "fontSize", value: { type: "unit", value: 16, unit: "px" }, }); expect(tokenStyles[2]).toMatchObject({ styleSourceId: tokens[2].id, breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }); }); // Case 6 safeguard: Different styles and different name gets suffix when name conflicts test("token with different styles and name adds suffix when name already exists", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "token1", type: "token", name: "primaryColor" }, { id: "token2", type: "token", name: "secondaryColor" }, // This name is taken ]); const styles = new Map([ createStyleDeclPair("token1", "base", "color", { type: "keyword", value: "blue", }), createStyleDeclPair("token2", "base", "color", { type: "keyword", value: "green", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styles }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, breakpoints: [{ id: "base", label: "base" }], styleSources: [ // Different styles from both existing tokens, but wants name "secondaryColor" { id: "token3", type: "token", name: "secondaryColor" }, ], styles: [ { styleSourceId: "token3", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ], }, availableVariables: [], projectId: "", }); // Should add counter suffix to prevent duplicate name const tokens = Array.from(data.styleSources.values()); expect(tokens).toHaveLength(3); expect(tokens[0]).toEqual({ id: "token1", type: "token", name: "primaryColor", }); expect(tokens[1]).toEqual({ id: "token2", type: "token", name: "secondaryColor", }); expect(tokens[2]).toMatchObject({ type: "token", name: "secondaryColor-1", }); expect(tokens[2].id).not.toBe("token3"); // Should have new ID const tokenStyles = Array.from(data.styles.values()); expect(tokenStyles).toHaveLength(3); expect(tokenStyles[0]).toEqual({ styleSourceId: "token1", breakpointId: "base", property: "color", value: { type: "keyword", value: "blue" }, }); expect(tokenStyles[1]).toEqual({ styleSourceId: "token2", breakpointId: "base", property: "color", value: { type: "keyword", value: "green" }, }); expect(tokenStyles[2]).toMatchObject({ styleSourceId: tokens[2].id, breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }); }); // Test that existing token with same styles but different name stays untouched test("existing token with matching styles but different name stays untouched when inserting new token", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "existingToken", type: "token", name: "primaryColor" }, ]); const styles = new Map([ createStyleDeclPair("existingToken", "base", "color", { type: "keyword", value: "red", }), createStyleDeclPair("existingToken", "base", "fontSize", { type: "unit", value: 16, unit: "px", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styles }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, breakpoints: [{ id: "base", label: "base" }], styleSources: [ // Different name "accentColor", same styles as existingToken { id: "newToken", type: "token", name: "accentColor" }, ], styles: [ { styleSourceId: "newToken", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, { styleSourceId: "newToken", breakpointId: "base", property: "fontSize", value: { type: "unit", value: 16, unit: "px" }, }, ], }, availableVariables: [], projectId: "", }); // Should insert new token with its own name, leaving existing one untouched const tokens = Array.from(data.styleSources.values()); expect(tokens).toHaveLength(2); expect(tokens[0]).toEqual({ id: "existingToken", type: "token", name: "primaryColor", }); expect(tokens[1]).toMatchObject({ type: "token", name: "accentColor" }); expect(tokens[1].id).not.toBe("newToken"); // Should have new ID // Both tokens should have their own styles const tokenStyles = Array.from(data.styles.values()); expect(tokenStyles).toHaveLength(4); expect(tokenStyles[0]).toEqual({ styleSourceId: "existingToken", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }); expect(tokenStyles[1]).toEqual({ styleSourceId: "existingToken", breakpointId: "base", property: "fontSize", value: { type: "unit", value: 16, unit: "px" }, }); expect(tokenStyles[2]).toMatchObject({ styleSourceId: tokens[1].id, breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }); expect(tokenStyles[3]).toMatchObject({ styleSourceId: tokens[1].id, breakpointId: "base", property: "fontSize", value: { type: "unit", value: 16, unit: "px" }, }); }); // Critical test: inserting base name when suffixed version exists test("inserting token 'bbb' when 'bbb-1' with same styles exists inserts both tokens", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "existingToken", type: "token", name: "bbb-1" }, ]); const styles = new Map([ createStyleDeclPair("existingToken", "base", "color", { type: "keyword", value: "blue", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styles }); // Add an instance that uses the existing token const existingInstance = createInstance("existingInstance", "Box", []); data.instances.set("existingInstance", existingInstance); data.styleSourceSelections.set("existingInstance", { instanceId: "existingInstance", values: ["existingToken"], }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, instances: [ { type: "instance", id: "box", component: "Box", children: [], }, ], breakpoints: [{ id: "base", label: "base" }], styleSources: [ // Inserting "bbb" with same styles as "bbb-1" { id: "newToken", type: "token", name: "bbb" }, ], styleSourceSelections: [{ instanceId: "box", values: ["newToken"] }], styles: [ { styleSourceId: "newToken", breakpointId: "base", property: "color", value: { type: "keyword", value: "blue" }, }, ], }, availableVariables: [], projectId: "", }); // Both tokens should exist const tokens = Array.from(data.styleSources.values()); expect(tokens).toHaveLength(2); expect(tokens[0]).toEqual({ id: "existingToken", type: "token", name: "bbb-1", }); expect(tokens[1]).toMatchObject({ type: "token", name: "bbb" }); // Different name, so inserted as-is expect(tokens[1].id).not.toBe("newToken"); // Should have new ID // Both should have their own styles const tokenStyles = Array.from(data.styles.values()); expect(tokenStyles).toHaveLength(2); expect(tokenStyles[0]).toEqual({ styleSourceId: "existingToken", breakpointId: "base", property: "color", value: { type: "keyword", value: "blue" }, }); expect(tokenStyles[1]).toMatchObject({ styleSourceId: tokens[1].id, breakpointId: "base", property: "color", value: { type: "keyword", value: "blue" }, }); // The EXISTING instance should still reference "bbb-1" (existingToken) expect(data.styleSourceSelections.get("existingInstance")).toEqual({ instanceId: "existingInstance", values: ["existingToken"], }); // The new instance should reference the new token "bbb" (tokens[1].id) const newBoxId = Array.from(data.instances.keys()).find( (id) => id !== "existingInstance" ); expect(data.styleSourceSelections.get(newBoxId!)).toEqual({ instanceId: newBoxId, values: [tokens[1].id], }); }); test("insert local styles with new ids and use merged breakpoint ids", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const data = getWebstudioDataStub({ breakpoints }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, instances: [ { type: "instance", id: "box", component: "Box", children: [], }, ], breakpoints: [{ id: "new_base", label: "Base" }], styleSourceSelections: [ { instanceId: "box", values: ["localId", "tokenId"] }, { instanceId: "unknown", values: [] }, ], styleSources: [{ id: "localId", type: "local" }], styles: [ { styleSourceId: "localId", breakpointId: "new_base", property: "color", value: { type: "keyword", value: "red" }, }, { styleSourceId: "tokenId", breakpointId: "new_base", property: "color", value: { type: "keyword", value: "green" }, }, ], }, availableVariables: [], projectId: "", }); expect(Array.from(data.styleSourceSelections.values())).toEqual([ { instanceId: expect.not.stringMatching("box"), values: [expect.not.stringMatching("localId"), "tokenId"], }, ]); expect(Array.from(data.styleSources.values())).toEqual([ { id: expect.not.stringMatching("localId"), type: "local" }, ]); expect(Array.from(data.styles.values())).toEqual([ { styleSourceId: expect.not.stringMatching("localId"), breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ]); }); test("insert local styles from portal and use merged breakpoint ids", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const data = getWebstudioDataStub({ breakpoints }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, // portal // fragment instances: [ { type: "instance", id: "portal", component: portalComponent, children: [{ type: "id", value: "fragment" }], }, { type: "instance", id: "fragment", component: "Fragment", children: [{ type: "id", value: "box" }], }, ], breakpoints: [{ id: "new_base", label: "Base" }], styleSourceSelections: [ { instanceId: "fragment", values: ["localId", "tokenId"] }, { instanceId: "unknown", values: [] }, ], styleSources: [{ id: "localId", type: "local" }], styles: [ { styleSourceId: "localId", breakpointId: "new_base", property: "color", value: { type: "keyword", value: "red" }, }, { styleSourceId: "tokenId", breakpointId: "new_base", property: "color", value: { type: "keyword", value: "green" }, }, ], }, availableVariables: [], projectId: "", }); expect(Array.from(data.styleSourceSelections.values())).toEqual([ { instanceId: "fragment", values: ["localId", "tokenId"] }, ]); expect(Array.from(data.styleSources.values())).toEqual([ { id: "localId", type: "local" }, ]); expect(Array.from(data.styles.values())).toEqual([ { styleSourceId: "localId", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ]); }); test("merge local styles into existing ROOT_INSTANCE_ID local source", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "existingLocal", type: "local" }, ]); const styleSourceSelections = new Map([ [ ROOT_INSTANCE_ID, { instanceId: ROOT_INSTANCE_ID, values: ["existingLocal"] }, ], ]); const styles = new Map([ createStyleDeclPair("existingLocal", "base", "fontSize", { type: "unit", value: 16, unit: "px", }), ]); const data = getWebstudioDataStub({ breakpoints, styleSources, styleSourceSelections, styles, }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, instances: [ { type: "instance", id: ROOT_INSTANCE_ID, component: "Body", children: [], }, ], breakpoints: [{ id: "base", label: "base" }], styleSourceSelections: [ { instanceId: ROOT_INSTANCE_ID, values: ["newLocal"] }, ], styleSources: [{ id: "newLocal", type: "local" }], styles: [ { styleSourceId: "newLocal", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ], }, availableVariables: [], projectId: "", }); // Should merge into existing local source expect(Array.from(data.styleSources.values())).toEqual([ { id: "existingLocal", type: "local" }, ]); expect(data.styleSourceSelections.get(ROOT_INSTANCE_ID)).toEqual({ instanceId: ROOT_INSTANCE_ID, values: ["existingLocal"], }); // Both styles should be present under the same source expect(Array.from(data.styles.values())).toEqual([ { styleSourceId: "existingLocal", breakpointId: "base", property: "fontSize", value: { type: "unit", value: 16, unit: "px" }, }, { styleSourceId: "existingLocal", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, ]); }); test("insert instances with new ids and update child references", () => { const data = getWebstudioDataStub(); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, instances: [ { type: "instance", id: "parent", component: "Box", children: [ { type: "id", value: "child1" }, { type: "id", value: "child2" }, ], }, { type: "instance", id: "child1", component: "Text", children: [], }, { type: "instance", id: "child2", component: "Text", children: [], }, ], }, availableVariables: [], projectId: "", }); const newInstances = Array.from(data.instances.values()); expect(newInstances).toHaveLength(3); const parentInstance = newInstances.find((i) => i.component === "Box"); const childInstances = newInstances.filter((i) => i.component === "Text"); expect(parentInstance).toBeDefined(); expect(childInstances).toHaveLength(2); // Verify parent's children reference the new child ids expect(parentInstance?.children).toEqual([ { type: "id", value: childInstances[0].id }, { type: "id", value: childInstances[1].id }, ]); // Verify new ids were generated (not same as original) expect(parentInstance?.id).not.toBe("parent"); expect(childInstances[0].id).not.toBe("child1"); expect(childInstances[1].id).not.toBe("child2"); }); test("skip portal content that already exists", () => { const instances = new Map([ [ "existingPortalContent", { type: "instance" as const, id: "existingPortalContent", component: "Box", children: [{ type: "text" as const, value: "existing" }], }, ], ]); const data = getWebstudioDataStub({ instances }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, instances: [ { type: "instance", id: "portal", component: portalComponent, children: [{ type: "id", value: "existingPortalContent" }], }, { type: "instance", id: "existingPortalContent", component: "Box", children: [{ type: "text", value: "new version" }], }, ], }, availableVariables: [], projectId: "", }); // Should preserve existing portal content const existingInstance = data.instances.get("existingPortalContent"); expect(existingInstance?.children).toEqual([ { type: "text", value: "existing" }, ]); }); test("insert instance with expression child and update variable references", () => { const data = getWebstudioDataStub(); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, instances: [ { type: "instance", id: "box", component: "Box", children: [{ type: "expression", value: "$ws$dataSource$oldVar" }], }, ], dataSources: [ { id: "oldVar", scopeInstanceId: "box", type: "variable", name: "myVar", value: { type: "string", value: "hello" }, }, ], }, availableVariables: [], projectId: "", }); const newBoxId = Array.from(data.instances.keys())[0]; const newBox = data.instances.get(newBoxId); const newVarId = Array.from(data.dataSources.keys())[0]; // Verify the expression was updated with the new variable id expect(newBox?.children[0].type).toBe("expression"); if (newBox?.children[0].type === "expression") { // The encoding replaces - with __DASH__ const encodedId = newVarId.replace(/-/g, "__DASH__"); expect(newBox.children[0].value).toBe(`$ws$dataSource$${encodedId}`); } expect(newVarId).not.toBe("oldVar"); }); test("skip global variables that already exist by id", () => { const dataSources = new Map([ [ "globalVar1", { id: "globalVar1", scopeInstanceId: ROOT_INSTANCE_ID, type: "variable" as const, name: "Global Var", value: { type: "string" as const, value: "existing" }, }, ], ]); const data = getWebstudioDataStub({ dataSources }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, dataSources: [ { id: "globalVar1", scopeInstanceId: ROOT_INSTANCE_ID, type: "variable", name: "Global Var", value: { type: "string", value: "new value" }, }, ], }, availableVariables: [], projectId: "", }); // Should preserve existing global variable const existingVar = data.dataSources.get("globalVar1"); expect(existingVar?.type).toBe("variable"); if (existingVar?.type === "variable") { expect(existingVar.value).toEqual({ type: "string", value: "existing", }); } }); test("skip global variables that have conflicting names with availableVariables", () => { const data = getWebstudioDataStub(); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, dataSources: [ { id: "newGlobal", scopeInstanceId: ROOT_INSTANCE_ID, type: "variable", name: "conflictingName", value: { type: "string", value: "value" }, }, ], }, availableVariables: [ { id: "existingId", scopeInstanceId: ROOT_INSTANCE_ID, type: "variable", name: "conflictingName", value: { type: "string", value: "existing" }, }, ], projectId: "", }); // Should not insert the global variable due to name conflict expect(data.dataSources.has("newGlobal")).toBe(false); }); test("handle mixed token and local style sources in styleSourceSelections", () => { const breakpoints = toMap([{ id: "base", label: "base" }]); const styleSources = toMap([ { id: "existingToken", type: "token", name: "Primary" }, ]); const data = getWebstudioDataStub({ breakpoints, styleSources }); insertWebstudioFragmentCopy({ data, fragment: { ...emptyFragment, instances: [ { type: "instance", id: "box", component: "Box", children: [], }, ], breakpoints: [{ id: "base", label: "base" }], styleSourceSelections: [ { instanceId: "box", values: ["localStyle1", "existingToken", "localStyle2"], }, ], styleSources: [ { id: "localStyle1", type: "local" }, { id: "localStyle2", type: "local" }, ], styles: [ { styleSourceId: "localStyle1", breakpointId: "base", property: "color", value: { type: "keyword", value: "red" }, }, { styleSourceId: "localStyle2", breakpointId: "base", property: "fontSize", value: { type: "unit", value: 16, unit: "px" }, }, ], }, availableVariables: [], projectId: "", }); const newBoxId = Array.from(data.instances.keys())[0]; const selection = data.styleSourceSelections.get(newBoxId); expect(selection?.values).toHaveLength(3); expect(selection?.values[1]).toBe("existingToken"); // Token preserved expect(selection?.values[0]).not.toBe("localStyle1"); // Local regenerated expect(selection?.values[2]).not.toBe("localStyle2"); // Local regenerated // Verify both local styles were created with new ids const localStyles = Array.from(data.styleSources.values()).filter( (s) => s.type === "local" ); expect(localStyles).toHaveLength(2); }); }); describe("find closest insertable", () => { const newBoxFragment = createFragment({ children: [{ type: "id", value: "newBoxId" }], instances: [ { type: "instance", id: "newBoxId", component: "Box", children: [] }, ], }); beforeEach(() => { $pages.set( createDefaultPages({ homePageId: "homePageId", rootInstanceId: "", }) ); $awareness.set({ pageId: "homePageId", instanceSelector: ["collectionId[1]", "collectionId", "bodyId"], }); $registeredComponentMetas.set(defaultMetasMap); }); test("puts in the end if closest instance is container", () => { const { instances } = renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="boxId"> <$.Paragraph ws:id="paragraphId"> <$.Bold ws:id="boldId"> ); $instances.set(instances); selectInstance(["boxId", "bodyId"]); expect(findClosestInsertable(newBoxFragment)).toEqual({ parentSelector: ["boxId", "bodyId"], position: "end", }); }); test("puts in the end of root instance", () => { const { instances } = renderData( <$.Body ws:id="bodyId"> <$.Paragraph ws:id="paragraphId"> ); $instances.set(instances); selectInstance(["bodyId"]); expect(findClosestInsertable(newBoxFragment)).toEqual({ parentSelector: ["bodyId"], position: "end", }); }); test("puts in the end of root instance when page root only has text", () => { const { instances } = renderData(<$.Body ws:id="bodyId">text); $instances.set(instances); selectInstance(["bodyId"]); expect(findClosestInsertable(newBoxFragment)).toEqual({ parentSelector: ["bodyId"], position: "end", }); }); test("finds closest container and puts after its child within selection", () => { const { instances } = renderData( <$.Body ws:id="bodyId"> <$.Paragraph ws:id="paragraphId"> <$.Bold ws:id="boldId"> ); $instances.set(instances); selectInstance(["boldId", "paragraphId", "bodyId"]); expect(findClosestInsertable(newBoxFragment)).toEqual({ parentSelector: ["bodyId"], position: 1, }); }); test("finds closest container that doesn't have an expression as a child", () => { const { instances } = renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="box1Id"> <$.Paragraph ws:id="paragraphId">{expression`"bla"`} <$.Box ws:id="box2Id"> ); $instances.set(instances); selectInstance(["paragraphId", "bodyId"]); expect(findClosestInsertable(newBoxFragment)).toEqual({ parentSelector: ["bodyId"], position: 2, }); }); test("finds closest container without textual placeholder", () => { const { instances } = renderData( <$.Body ws:id="bodyId"> <$.Paragraph ws:id="paragraphId"> ); $instances.set(instances); selectInstance(["paragraphId", "bodyId"]); expect(findClosestInsertable(newBoxFragment)).toEqual({ parentSelector: ["bodyId"], position: 1, }); }); test("finds closest container even with when parent has placeholder", () => { const { instances } = renderData( <$.Body ws:id="bodyId"> <$.Paragraph ws:id="paragraphId"> <$.Image ws:id="imageId"> ); $instances.set(instances); selectInstance(["imageId", "paragraphId", "bodyId"]); expect( findClosestInsertable(renderTemplate(<$.Box ws:tag="span">)) ).toEqual({ parentSelector: ["paragraphId", "bodyId"], position: 1, }); }); test("forbids inserting into :root", () => { const { instances } = renderData(<$.Body ws:id="bodyId">); $instances.set(instances); selectInstance([":root"]); expect(findClosestInsertable(newBoxFragment)).toEqual(undefined); }); test("allow inserting into collection item", () => { const { instances } = renderData( <$.Body ws:id="bodyId"> <$.Box ws:id="boxId"> ); $instances.set(instances); selectInstance(["collectionId[1]", "collectionId", "bodyId"]); expect(findClosestInsertable(newBoxFragment)).toEqual({ parentSelector: ["collectionId", "bodyId"], position: "end", }); }); test("allow inserting list item in body even though validation fails", () => { const { instances } = renderData(<$.Body ws:id="bodyId">); $instances.set(instances); selectInstance(["bodyId"]); const newListItemFragment = renderTemplate( <$.ListItem ws:id="newListItemId"> ); expect(findClosestInsertable(newListItemFragment)).toEqual({ parentSelector: ["bodyId"], position: "end", }); }); }); test("get undefined instead of instance path when no instances found", () => { expect(getInstancePath(["boxId"], new Map())).toEqual(undefined); }); describe("buildInstancePath", () => { const createPages = () => createDefaultPages({ homePageId: "homePageId", rootInstanceId: "rootId", systemDataSourceId: "systemId", }); test("returns empty array when instance has no selector", () => { const pages = createPages(); const instances = new Map(); const result = buildInstancePath("nonexistent", pages, instances); expect(result).toEqual([]); }); test("returns empty array for root instance (no ancestors)", () => { const { instances } = renderData( <$.Body ws:id="rootId"> <$.Box ws:id="boxId"> ); const pages = createPages(); const result = buildInstancePath("rootId", pages, instances); expect(result).toEqual([]); }); test("builds path for single-level nesting", () => { const { instances } = renderData( <$.Body ws:id="rootId"> <$.Box ws:id="boxId"> ); const pages = createPages(); const result = buildInstancePath("boxId", pages, instances); expect(result).toEqual(["Body"]); }); test("builds path for multi-level nesting", () => { const { instances } = renderData( <$.Body ws:id="rootId"> <$.Box ws:id="containerId"> <$.Heading ws:id="headingId"> ); const pages = createPages(); const result = buildInstancePath("headingId", pages, instances); expect(result).toEqual(["Body", "Box"]); }); test("builds path for deeply nested instance", () => { const { instances } = renderData( <$.Body ws:id="rootId"> <$.Box ws:id="sectionId"> <$.Box ws:id="articleId"> <$.Box ws:id="divId"> <$.Text ws:id="textId"> ); const pages = createPages(); const result = buildInstancePath("textId", pages, instances); expect(result).toEqual(["Body", "Box", "Box", "Box"]); }); test("handles instances with custom labels", () => { const { instances } = renderData( <$.Body ws:id="rootId" ws:label="Main Body"> <$.Box ws:id="navId" ws:label="Navigation"> <$.Link ws:id="linkId" ws:label="Home Link"> ); const pages = createPages(); const result = buildInstancePath("linkId", pages, instances); expect(result).toEqual(["Main Body", "Navigation"]); }); }); describe("toggleInstanceShow", () => { test("creates show prop with false value when it doesn't exist", () => { const { instances } = renderData( <$.Body ws:id="body"> <$.Box ws:id="box"> ); $instances.set(instances); $props.set(new Map()); $pages.set(createDefaultPages({ rootInstanceId: "body" })); toggleInstanceShow("box"); const props = $props.get(); const showProp = Array.from(props.values()).find( (prop) => prop.instanceId === "box" && prop.name === showAttribute ); expect(showProp).toEqual({ id: expect.any(String), instanceId: "box", name: showAttribute, type: "boolean", value: false, }); }); test("toggles show prop from true to false", () => { const { instances, props } = renderData( <$.Body ws:id="body"> <$.Box ws:id="box" ws:show={true}> ); $instances.set(instances); $props.set(props); $pages.set(createDefaultPages({ rootInstanceId: "body" })); toggleInstanceShow("box"); const updatedProps = $props.get(); const showProp = Array.from(updatedProps.values()).find( (prop) => prop.instanceId === "box" && prop.name === showAttribute ); expect(showProp?.type).toBe("boolean"); if (showProp?.type === "boolean") { expect(showProp.value).toBe(false); } }); test("toggles show prop from false to true", () => { const { instances, props } = renderData( <$.Body ws:id="body"> <$.Box ws:id="box" ws:show={false}> ); $instances.set(instances); $props.set(props); $pages.set(createDefaultPages({ rootInstanceId: "body" })); toggleInstanceShow("box"); const updatedProps = $props.get(); const showProp = Array.from(updatedProps.values()).find( (prop) => prop.instanceId === "box" && prop.name === showAttribute ); expect(showProp?.type).toBe("boolean"); if (showProp?.type === "boolean") { expect(showProp.value).toBe(true); } }); }); describe("unwrap instance", () => { test("unwraps instance and moves children to parent", () => { const { instances, props } = renderData( <$.Body ws:id="body"> <$.Box ws:id="parent"> <$.Box ws:id="wrapper"> <$.Box ws:id="child1"> <$.Box ws:id="child2"> ); const selectedItem = { instanceSelector: ["wrapper", "parent", "body"], instance: instances.get("wrapper")!, }; const parentItem = { instanceSelector: ["parent", "body"], instance: instances.get("parent")!, }; const result = unwrapInstanceMutable({ instances, props, metas: defaultMetasMap, selectedItem, parentItem, }); expect(result.success).toBe(true); expect(instances.has("parent")).toBe(false); const bodyInstance = instances.get("body")!; expect(bodyInstance.children).toEqual([{ type: "id", value: "wrapper" }]); }); test("fails to unwrap textual instance", () => { const { instances, props } = renderData( <$.Body ws:id="body"> <$.Box ws:id="box"> <$.Paragraph ws:id="paragraph"> <$.Bold ws:id="bold">text ); const selectedItem = { instanceSelector: ["bold", "paragraph", "box", "body"], instance: instances.get("bold")!, }; const parentItem = { instanceSelector: ["paragraph", "box", "body"], instance: instances.get("paragraph")!, }; const result = unwrapInstanceMutable({ instances, props, metas: defaultMetasMap, selectedItem, parentItem, }); expect(result.success).toBe(false); expect(result.error).toBe("Cannot unwrap textual instance"); }); test("fails to unwrap if content model is violated", () => { const { instances, props } = renderData( <$.Body ws:id="body"> <$.Box ws:id="outerBox"> <$.Form ws:id="form"> <$.Box ws:id="innerBox"> <$.Form ws:id="nestedForm"> ); const selectedItem = { instanceSelector: ["form", "outerBox", "body"], instance: instances.get("form")!, }; const parentItem = { instanceSelector: ["outerBox", "body"], instance: instances.get("outerBox")!, }; const result = unwrapInstanceMutable({ instances, props, metas: defaultMetasMap, selectedItem, parentItem, }); expect(result.success).toBe(false); expect(result.error).toBe("Cannot unwrap instance"); }); test("unwrapping replaces parent with selected in grandparent", () => { const { instances, props } = renderData( <$.Body ws:id="body"> <$.Box ws:id="parent"> <$.Box ws:id="child"> ); const selectedItem = { instanceSelector: ["child", "parent", "body"], instance: instances.get("child")!, }; const parentItem = { instanceSelector: ["parent", "body"], instance: instances.get("parent")!, }; const result = unwrapInstanceMutable({ instances, props, metas: defaultMetasMap, selectedItem, parentItem, }); expect(result.success).toBe(true); expect(instances.has("parent")).toBe(false); expect(instances.has("child")).toBe(true); const bodyInstance = instances.get("body")!; expect(bodyInstance.children).toEqual([{ type: "id", value: "child" }]); }); test("unwrapping removes selected from parent and moves it to grandparent", () => { const { instances, props } = renderData( <$.Body ws:id="body"> <$.Box ws:id="parent"> <$.Image ws:id="image"> <$.Link ws:id="link"> ); const selectedItem = { instanceSelector: ["image", "parent", "body"], instance: instances.get("image")!, }; const parentItem = { instanceSelector: ["parent", "body"], instance: instances.get("parent")!, }; const result = unwrapInstanceMutable({ instances, props, metas: defaultMetasMap, selectedItem, parentItem, }); expect(result.success).toBe(true); expect(instances.has("parent")).toBe(true); // Parent still exists expect(instances.has("image")).toBe(true); expect(instances.has("link")).toBe(true); const bodyInstance = instances.get("body")!; expect(bodyInstance.children).toEqual([ { type: "id", value: "parent" }, { type: "id", value: "image" }, ]); const parentInstance = instances.get("parent")!; expect(parentInstance.children).toEqual([{ type: "id", value: "link" }]); }); }); describe("canUnwrapInstance", () => { beforeEach(() => { $project.set({ id: "projectId" } as Project); $registeredComponentMetas.set(defaultMetasMap); }); test("returns true for unwrappable instance", () => { const { instances, props } = renderData( <$.Body ws:id="body"> <$.Box ws:id="parent"> <$.Box ws:id="wrapper"> <$.Box ws:id="child"> ); $instances.set(instances); $props.set(props); const pages = createDefaultPages({ rootInstanceId: "body" }); $pages.set(pages); $awareness.set({ pageId: pages.homePage.id }); const instancePath = [ { instanceSelector: ["wrapper", "parent", "body"], instance: instances.get("wrapper")!, }, { instanceSelector: ["parent", "body"], instance: instances.get("parent")!, }, { instanceSelector: ["body"], instance: instances.get("body")!, }, ] satisfies InstancePath; expect(canUnwrapInstance(instancePath)).toBe(true); }); test("returns false if parent is root instance", () => { const { instances, props } = renderData( <$.Body ws:id="body"> <$.Box ws:id="box"> ); $instances.set(instances); $props.set(props); $registeredComponentMetas.set(defaultMetasMap); const pages = createDefaultPages({ rootInstanceId: "body" }); $pages.set(pages); $awareness.set({ pageId: pages.homePage.id }); const instancePath = [ { instanceSelector: ["box", "body"], instance: instances.get("box")!, }, { instanceSelector: ["body"], instance: instances.get("body")!, }, ] satisfies InstancePath; expect(canUnwrapInstance(instancePath)).toBe(false); }); test("returns false for textual instance", () => { const { instances, props } = renderData( <$.Body ws:id="body"> <$.Paragraph ws:id="paragraph"> <$.Bold ws:id="bold">text ); $instances.set(instances); $props.set(props); $registeredComponentMetas.set(defaultMetasMap); const pages = createDefaultPages({ rootInstanceId: "body" }); $pages.set(pages); $awareness.set({ pageId: pages.homePage.id }); const instancePath = [ { instanceSelector: ["bold", "paragraph", "body"], instance: instances.get("bold")!, }, { instanceSelector: ["paragraph", "body"], instance: instances.get("paragraph")!, }, ] satisfies InstancePath; expect(canUnwrapInstance(instancePath)).toBe(false); }); test("returns true for Body > div > a scenario", () => { const { instances, props } = renderData( <$.Body ws:id="body"> Link text ); $instances.set(instances); $props.set(props); $registeredComponentMetas.set(defaultMetasMap); const pages = createDefaultPages({ rootInstanceId: "body" }); $pages.set(pages); $awareness.set({ pageId: pages.homePage.id }); const instancePath = [ { instanceSelector: ["link", "div", "body"], instance: instances.get("link")!, }, { instanceSelector: ["div", "body"], instance: instances.get("div")!, }, { instanceSelector: ["body"], instance: instances.get("body")!, }, ] satisfies InstancePath; // Should be able to unwrap the link from the div expect(canUnwrapInstance(instancePath)).toBe(true); }); test("unwrapInstanceMutable works for Body > div > a scenario", () => { const { instances, props } = renderData( <$.Body ws:id="body"> Link text ); const result = unwrapInstanceMutable({ instances, props, metas: defaultMetasMap, selectedItem: { instanceSelector: ["link", "div", "body"], instance: instances.get("link")!, }, parentItem: { instanceSelector: ["div", "body"], instance: instances.get("div")!, }, }); expect(result.success).toBe(true); // Verify the link is now a direct child of body const body = instances.get("body")!; expect(body.children).toContainEqual({ type: "id", value: "link" }); // Verify the div was deleted since it has no more children expect(instances.has("div")).toBe(false); }); }); describe("canConvertInstance", () => { test("returns true for valid conversion", () => { const { instances, props } = renderData( <$.Box ws:id="box"> ); const result = canConvertInstance( "box", ["box", "body"], elementComponent, "div", instances, props, defaultMetasMap ); expect(result).toBe(true); }); test("returns false for non-existent instance", () => { const { instances, props } = renderData( ); const result = canConvertInstance( "nonexistent", ["nonexistent", "body"], elementComponent, "div", instances, props, defaultMetasMap ); expect(result).toBe(false); }); test("returns true when converting Box to Heading", () => { const { instances, props } = renderData( <$.Box ws:id="box"> ); const result = canConvertInstance( "box", ["box", "body"], "@webstudio-is/sdk-components-react:Heading", undefined, instances, props, defaultMetasMap ); expect(result).toBe(true); }); test("uses preset tag when available", () => { const { instances, props } = renderData( <$.Box ws:id="box"> ); const result = canConvertInstance( "box", ["box", "body"], "@webstudio-is/sdk-components-react:Heading", undefined, instances, props, defaultMetasMap ); expect(result).toBe(true); }); }); describe("convertInstance", () => { beforeEach(() => { $registeredComponentMetas.set(defaultMetasMap); }); test("converts legacy tag to element", () => { const { instances, props } = renderData( <$.Box tag="article" ws:id="articleId"> ); $instances.set(instances); $props.set(props); selectInstance(["articleId", "bodyId"]); convertInstance(elementComponent); const { instances: newInstances, props: newProps } = renderData( ); expect({ instances: $instances.get(), props: $props.get() }).toEqual({ instances: newInstances, props: newProps, }); }); test("migrates legacy properties", () => { const { instances, props } = renderData( <$.Box ws:tag="div" ws:id="divId" className="my-class" htmlFor="my-id" > ); $instances.set(instances); $props.set(props); selectInstance(["divId", "bodyId"]); convertInstance(elementComponent); const { instances: newInstances, props: newProps } = renderData( ); expect({ instances: $instances.get(), props: $props.get() }).toEqual({ instances: newInstances, props: newProps, }); }); test("preserves currently specified tag", () => { $instances.set( renderData( <$.Box ws:tag="article" ws:id="articleId"> ).instances ); selectInstance(["articleId", "bodyId"]); convertInstance(elementComponent); expect($instances.get()).toEqual( renderData( ).instances ); }); test("converts to first tag from presets", () => { $instances.set( renderData( <$.Heading ws:id="headingId"> ).instances ); selectInstance(["headingId", "bodyId"]); convertInstance(elementComponent); expect($instances.get()).toEqual( renderData( ).instances ); }); test("falls back to div", () => { $instances.set( renderData( <$.Box ws:id="divId"> ).instances ); selectInstance(["divId", "bodyId"]); convertInstance(elementComponent); expect($instances.get()).toEqual( renderData( ).instances ); }); test("converts with specific tag", () => { $instances.set( renderData( <$.Box ws:id="divId"> ).instances ); selectInstance(["divId", "bodyId"]); convertInstance(elementComponent, "a"); expect($instances.get()).toEqual( renderData( ).instances ); }); test("converts between components", () => { $instances.set( renderData( <$.Box ws:id="boxId"> ).instances ); selectInstance(["boxId", "bodyId"]); convertInstance("@webstudio-is/sdk-components-react:Heading"); const result = $instances.get(); const boxInstance = result.get("boxId"); expect(boxInstance?.component).toBe( "@webstudio-is/sdk-components-react:Heading" ); }); test("prevents converting root instance", () => { const initialInstances = renderData( ).instances; $instances.set(initialInstances); selectInstance(["rootId"]); convertInstance(elementComponent, "div"); // Should not change expect($instances.get()).toEqual(initialInstances); }); test("prevents converting body instance", () => { const pages = createDefaultPages({ rootInstanceId: "bodyId" }); $pages.set(pages); $awareness.set({ pageId: pages.homePage.id }); const initialInstances = renderData( <$.Box ws:id="boxId"> ).instances; $instances.set(initialInstances); selectInstance(["bodyId", "rootId"]); convertInstance(elementComponent, "div"); // Should not change expect($instances.get()).toEqual(initialInstances); }); }); describe("deleteSelectedInstance", () => { test("delete selected instance and select next one", () => { const { instances } = renderData( <$.Body ws:id="body"> <$.Box ws:id="parent"> <$.Box ws:id="child1"> <$.Box ws:id="child2"> <$.Box ws:id="child3"> ); $instances.set(instances); const pages = createDefaultPages({ rootInstanceId: "body" }); $pages.set(pages); $awareness.set({ pageId: pages.homePage.id }); selectInstance(["child2", "parent", "body"]); deleteSelectedInstance(); expect($awareness.get()?.instanceSelector).toEqual([ "child3", "parent", "body", ]); }); test("delete selected instance and select previous one", () => { const { instances } = renderData( <$.Body ws:id="body"> <$.Box ws:id="parent"> <$.Box ws:id="child1"> <$.Box ws:id="child2"> <$.Box ws:id="child3"> ); $instances.set(instances); const pages = createDefaultPages({ rootInstanceId: "body" }); $pages.set(pages); $awareness.set({ pageId: pages.homePage.id }); selectInstance(["child3", "parent", "body"]); deleteSelectedInstance(); expect($awareness.get()?.instanceSelector).toEqual([ "child2", "parent", "body", ]); }); test("delete selected instance and select parent one", () => { const { instances } = renderData( <$.Body ws:id="body"> <$.Box ws:id="parent"> <$.Box ws:id="child1"> ); $instances.set(instances); const pages = createDefaultPages({ rootInstanceId: "body" }); $pages.set(pages); $awareness.set({ pageId: pages.homePage.id }); selectInstance(["child1", "parent", "body"]); deleteSelectedInstance(); expect($awareness.get()?.instanceSelector).toEqual(["parent", "body"]); }); });