Files
webstudio/apps/builder/app/shared/instance-utils.test.tsx
T
Bogdan Chadkin 4e6a448d96 fix: insert marketplace sections after selected instance (#4918)
Closes https://github.com/webstudio-is/webstudio/issues/4902

Rapid prototyping requires slightly different logic for inserting from
marketplace so sections could be added one after another.
2025-02-27 01:44:21 +07:00

1508 lines
44 KiB
TypeScript

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,
Instances,
Prop,
StyleDecl,
StyleDeclKey,
StyleSource,
WebstudioData,
WebstudioFragment,
WsComponentMeta,
} from "@webstudio-is/sdk";
import {
coreMetas,
portalComponent,
collectionComponent,
} from "@webstudio-is/sdk";
import type { StyleProperty, StyleValue } from "@webstudio-is/css-engine";
import {
findClosestEditableInstanceSelector,
deleteInstanceMutable,
extractWebstudioFragment,
insertWebstudioFragmentCopy,
reparentInstance,
getWebstudioData,
insertInstanceChildrenMutable,
findClosestInsertable,
insertWebstudioFragmentAt,
} from "./instance-utils";
import {
$assets,
$breakpoints,
$dataSources,
$instances,
$pages,
$project,
$props,
$styleSourceSelections,
$styleSources,
$styles,
$registeredComponentMetas,
$resources,
} from "./nano-states";
import { registerContainers } from "./sync";
import { $awareness, getInstancePath, selectInstance } from "./awareness";
enableMapSet();
registerContainers();
$pages.set(createDefaultPages({ rootInstanceId: "" }));
const defaultMetasMap = new Map(
Object.entries({ ...defaultMetas, ...coreMetas })
);
$registeredComponentMetas.set(defaultMetasMap);
const createFragment = (
fragment: Partial<WebstudioFragment>
): WebstudioFragment => ({
children: [],
instances: [],
styleSourceSelections: [],
styleSources: [],
breakpoints: [],
styles: [],
dataSources: [],
resources: [],
props: [],
assets: [],
...fragment,
});
const createFakeComponentMetas = (
itemMeta: Partial<WsComponentMeta>,
anotherItemMeta?: Partial<WsComponentMeta>
): Map<string, WsComponentMeta> => {
const base = {
label: "",
Icon: () => null,
};
const configs = {
Item: { ...base, type: "container", ...itemMeta },
AnotherItem: { ...base, type: "container", ...anotherItemMeta },
Bold: { ...base, type: "rich-text-child" },
Text: { ...base, type: "container" },
Form: { ...base, type: "container" },
Box: { ...base, type: "container" },
Div: { ...base, type: "container" },
Body: { ...base, type: "container" },
} as const;
return new Map(Object.entries(configs)) as Map<string, WsComponentMeta>;
};
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 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("find closest editable instance selector", () => {
test("searches closest container", () => {
const instances: Instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "box" }]),
createInstance("box", "Box", [
{ type: "text", value: "some text" },
{ type: "id", value: "bold" },
]),
createInstance("bold", "Bold", [{ type: "text", value: "some-bold" }]),
]);
expect(
findClosestEditableInstanceSelector(
["bold", "box", "body"],
instances,
createFakeComponentMetas({})
)
).toEqual(["box", "body"]);
expect(
findClosestEditableInstanceSelector(
["box", "body"],
instances,
createFakeComponentMetas({})
)
).toEqual(["box", "body"]);
});
test("skips when container has anything except rich-text-child or text", () => {
const instances: Instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "box" }]),
createInstance("box", "Box", [
{ type: "text", value: "some text" },
{ type: "id", value: "bold" },
{ type: "id", value: "child-box" },
]),
createInstance("bold", "Bold", [{ type: "text", value: "some-bold" }]),
createInstance("child-box", "Box", [
{ type: "text", value: "child-box" },
]),
]);
expect(
findClosestEditableInstanceSelector(
["bold", "box", "body"],
instances,
createFakeComponentMetas({})
)
).toEqual(undefined);
});
test("considers empty container as editable", () => {
const instances: Instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "body" }]),
createInstance("box", "Box", []),
]);
expect(
findClosestEditableInstanceSelector(
["box", "body"],
instances,
createFakeComponentMetas({})
)
).toEqual(["box", "body"]);
});
test("prevent editing Body instance", () => {
const instances: Instances = toMap([createInstance("body", "Body", [])]);
expect(
findClosestEditableInstanceSelector(
["body"],
instances,
createFakeComponentMetas({})
)
).toEqual(undefined);
});
});
describe("insert instance children", () => {
test("insert instance children into empty target", () => {
const instances = toMap([createInstance("body", "Body", [])]);
const data = getWebstudioDataStub({ instances });
insertInstanceChildrenMutable(data, [{ type: "id", value: "box" }], {
parentSelector: ["body"],
position: "end",
});
expect(data.instances).toEqual(
toMap([createInstance("body", "Body", [{ type: "id", value: "box" }])])
);
});
test("insert instance children into the end of target", () => {
const instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "text" }]),
]);
const data = getWebstudioDataStub({ instances });
insertInstanceChildrenMutable(data, [{ type: "id", value: "box" }], {
parentSelector: ["body"],
position: "end",
});
expect(data.instances).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "text" },
{ type: "id", value: "box" },
]),
])
);
});
test("insert instance children into the start of target", () => {
const instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "text" }]),
]);
const data = getWebstudioDataStub({ instances });
insertInstanceChildrenMutable(data, [{ type: "id", value: "box" }], {
parentSelector: ["body"],
position: 0,
});
expect(data.instances).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "box" },
{ type: "id", value: "text" },
]),
])
);
});
test("insert instance children at the start of text", () => {
const instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "text" }]),
createInstance("text", "Text", [{ type: "text", value: "text" }]),
]);
const data = getWebstudioDataStub({ instances });
insertInstanceChildrenMutable(data, [{ type: "id", value: "box" }], {
parentSelector: ["text", "body"],
position: 0,
});
const [_bodyId, _textId, spanId] = data.instances.keys();
expect(data.instances).toEqual(
toMap([
createInstance("body", "Body", [{ type: "id", value: "text" }]),
createInstance("text", "Text", [
{ type: "id", value: "box" },
{ type: "id", value: spanId },
]),
createInstance(spanId, "Text", [{ type: "text", value: "text" }]),
])
);
expect(data.props).toEqual(
toMap<Prop>([
{
id: expect.any(String) as unknown as string,
instanceId: spanId,
name: "tag",
type: "string",
value: "span",
},
])
);
});
test("insert instance children at the end of text", () => {
const instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "text" }]),
createInstance("text", "Text", [{ type: "text", value: "text" }]),
]);
const data = getWebstudioDataStub({ instances });
insertInstanceChildrenMutable(data, [{ type: "id", value: "box" }], {
parentSelector: ["text", "body"],
position: "end",
});
const [_bodyId, _textId, spanId] = data.instances.keys();
expect(data.instances).toEqual(
toMap([
createInstance("body", "Body", [{ type: "id", value: "text" }]),
createInstance("text", "Text", [
{ type: "id", value: spanId },
{ type: "id", value: "box" },
]),
createInstance(spanId, "Text", [{ type: "text", value: "text" }]),
])
);
expect(data.props).toEqual(
toMap<Prop>([
{
id: expect.any(String) as unknown as string,
instanceId: spanId,
name: "tag",
type: "string",
value: "span",
},
])
);
});
test("insert instance children between text children", () => {
const instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "text" }]),
createInstance("text", "Text", [
{ type: "id", value: "bold" },
{ type: "text", value: "text" },
{ type: "id", value: "italic" },
]),
createInstance("bold", "Bold", [{ type: "text", value: "bold" }]),
createInstance("italic", "Italic", [{ type: "text", value: "italic" }]),
]);
const data = getWebstudioDataStub({ instances });
insertInstanceChildrenMutable(data, [{ type: "id", value: "box" }], {
parentSelector: ["text", "body"],
position: 1,
});
const [_bodyId, _textId, _boldId, _italicId, leftSpanId, rightSpanId] =
data.instances.keys();
expect(data.instances).toEqual(
toMap([
createInstance("body", "Body", [{ type: "id", value: "text" }]),
createInstance("text", "Text", [
{ type: "id", value: leftSpanId },
{ type: "id", value: "box" },
{ type: "id", value: rightSpanId },
]),
createInstance("bold", "Bold", [{ type: "text", value: "bold" }]),
createInstance("italic", "Italic", [{ type: "text", value: "italic" }]),
createInstance(leftSpanId, "Text", [{ type: "id", value: "bold" }]),
createInstance(rightSpanId, "Text", [
{ type: "text", value: "text" },
{ type: "id", value: "italic" },
]),
])
);
expect(data.props).toEqual(
toMap<Prop>([
{
id: expect.any(String) as unknown as string,
instanceId: leftSpanId,
name: "tag",
type: "string",
value: "span",
},
{
id: expect.any(String) as unknown as string,
instanceId: rightSpanId,
name: "tag",
type: "string",
value: "span",
},
])
);
});
});
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"></$.Body>).instances);
insertWebstudioFragmentAt(
renderTemplate(
<>
<$.Heading ws:id="headingId"></$.Heading>
<$.Paragraph ws:id="paragraphId"></$.Paragraph>
</>
),
{
parentSelector: ["bodyId"],
position: "end",
}
);
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<$.Heading ws:id={expect.any(String)}></$.Heading>
<$.Paragraph ws:id={expect.any(String)}></$.Paragraph>
</$.Body>
).instances
);
});
test("insert fragment after insertable", () => {
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<$.Box ws:id="boxId"></$.Box>
</$.Body>
).instances
);
insertWebstudioFragmentAt(
renderTemplate(<$.Heading ws:id="headingId"></$.Heading>),
{
parentSelector: ["boxId", "bodyId"],
position: "after",
}
);
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<$.Box ws:id="boxId"></$.Box>
<$.Heading ws:id={expect.any(String)}></$.Heading>
</$.Body>
).instances
);
});
test("insert fragment inside of body when configured to place after insertable", () => {
$instances.set(renderData(<$.Body ws:id="bodyId"></$.Body>).instances);
insertWebstudioFragmentAt(
renderTemplate(<$.Heading ws:id="headingId"></$.Heading>),
{
parentSelector: ["bodyId"],
position: "after",
}
);
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<$.Heading ws:id={expect.any(String)}></$.Heading>
</$.Body>
).instances
);
});
});
describe("reparent instance", () => {
test("between instances", () => {
// body
// box
// text
// button
$instances.set(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "box" },
{ type: "id", value: "button" },
]),
createInstance("box", "Box", [{ type: "id", value: "text" }]),
createInstance("button", "Button", []),
createInstance("text", "Text", []),
])
);
$registeredComponentMetas.set(createFakeComponentMetas({}));
reparentInstance(["text", "box", "body"], {
parentSelector: ["body"],
position: 1,
});
const instances = $instances.get();
const newTextId = instances.get("body")?.children[1].value as string;
// body
// box
// text
// button
expect(instances).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "box" },
{ type: "id", value: newTextId },
{ type: "id", value: "button" },
]),
createInstance("box", "Box", []),
createInstance("button", "Button", []),
createInstance(newTextId, "Text", []),
])
);
});
test("to the end", () => {
// body
// box
// text
$instances.set(
toMap([
createInstance("body", "Body", [{ type: "id", value: "box" }]),
createInstance("box", "Box", [{ type: "id", value: "text" }]),
createInstance("text", "Text", []),
])
);
$registeredComponentMetas.set(createFakeComponentMetas({}));
reparentInstance(["text", "box", "body"], {
parentSelector: ["body"],
position: "end",
});
const instances = $instances.get();
const newTextId = instances.get("body")?.children[1].value as string;
// body
// box
// text
expect(instances).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "box" },
{ type: "id", value: newTextId },
]),
createInstance("box", "Box", []),
createInstance(newTextId, "Text", []),
])
);
});
test("wrap with fragment when reparent into empty portal", () => {
// body
// portal
// box
$instances.set(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "box" },
]),
createInstance("portal", portalComponent, []),
createInstance("box", "Box", []),
])
);
$registeredComponentMetas.set(createFakeComponentMetas({}));
reparentInstance(["box", "body"], {
parentSelector: ["portal", "body"],
position: "end",
});
const instances = $instances.get();
const newFragmentId = instances.get("portal")?.children[0].value as string;
const newBoxId = instances.get(newFragmentId)?.children[0].value as string;
// body
// portal
// fragment
// box
expect(instances).toEqual(
toMap([
createInstance("body", "Body", [{ type: "id", value: "portal" }]),
createInstance("portal", portalComponent, [
{ type: "id", value: newFragmentId },
]),
createInstance(newFragmentId, "Fragment", [
{ type: "id", value: newBoxId },
]),
createInstance(newBoxId, "Box", []),
])
);
});
test("reuse existing fragment when reparent into portal", () => {
// body
// portal
// fragment
// box
$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", []),
])
);
$registeredComponentMetas.set(createFakeComponentMetas({}));
reparentInstance(["box", "body"], {
parentSelector: ["portal", "body"],
position: "end",
});
const instances = $instances.get();
const newBoxId = instances.get("fragment")?.children[0].value as string;
// body
// portal
// fragment
// box
expect(instances).toEqual(
toMap([
createInstance("body", "Body", [{ type: "id", value: "portal" }]),
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", [
{ type: "id", value: newBoxId },
]),
createInstance(newBoxId, "Box", []),
])
);
});
test("prevent portal reparenting into own children to avoid infinite loop", () => {
// body
// portal
// fragment
$instances.set(
toMap([
createInstance("body", "Body", [{ type: "id", value: "portal" }]),
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
])
);
$registeredComponentMetas.set(createFakeComponentMetas({}));
reparentInstance(["portal", "body"], {
parentSelector: ["fragment", "portal", "body"],
position: "end",
});
const instances = $instances.get();
// body
// portal
// fragment
expect(instances).toEqual(
toMap([
createInstance("body", "Body", [{ type: "id", value: "portal" }]),
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
])
);
});
test("from collection item", () => {
// body
// list
// box
$instances.set(
toMap([
createInstance("body", "Body", [{ type: "id", value: "list" }]),
createInstance("list", collectionComponent, [
{ type: "id", value: "box" },
]),
createInstance("box", "Box", []),
])
);
$registeredComponentMetas.set(createFakeComponentMetas({}));
reparentInstance(["box", "list[0]", "list", "body"], {
parentSelector: ["body"],
position: "end",
});
const instances = $instances.get();
const newBoxId = instances.get("body")?.children[1].value as string;
// body
// list
// box
expect(instances).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "list" },
{ type: "id", value: newBoxId },
]),
createInstance("list", collectionComponent, []),
createInstance(newBoxId, "Box", []),
])
);
});
test("reparent required child", () => {
$instances.set(
renderData(
<$.Body ws:id="body">
<$.Tooltip ws:id="tooltip">
<$.TooltipTrigger ws:id="trigger"></$.TooltipTrigger>
<$.TooltipContent ws:id="content"></$.TooltipContent>
</$.Tooltip>
</$.Body>
).instances
);
$registeredComponentMetas.set(
new Map(Object.entries({ ...defaultMetas, ...radixMetas }))
);
reparentInstance(["trigger", "tooltip", "body"], {
parentSelector: ["tooltip", "body"],
position: "end",
});
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="body">
<$.Tooltip ws:id="tooltip">
<$.TooltipContent ws:id="content"></$.TooltipContent>
<$.TooltipTrigger ws:id={expect.any(String)}></$.TooltipTrigger>
</$.Tooltip>
</$.Body>
).instances
);
});
});
const getWebstudioDataStub = (
data?: Partial<WebstudioData>
): 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>
</$.Box>
<$.Box ws:id="divId"></$.Box>
</$.Body>
);
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">
<ws.collection ws:id="collectionId">
<$.Box ws:id="boxId"></$.Box>
</ws.collection>
</$.Body>
);
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">
<ws.collection ws:id="collectionId">
<$.Box ws:id="boxId">
<$.Text ws:id="textId"></$.Text>
</$.Box>
</ws.collection>
</$.Body>
);
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",
headers: [],
});
const data = renderData(
<$.Body ws:id="bodyId">
<$.Box ws:id="boxId" vars={expression`${myResource}`}></$.Box>
</$.Body>
);
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",
headers: [],
});
const data = renderData(
<$.Body ws:id="bodyId">
<$.Box ws:id="boxId" action={myResource}></$.Box>
</$.Body>
);
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"></$.Invalid>
</$.Body>
);
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"></$.Box>
</$.Fragment>
</$.Slot>
</$.Body>
);
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("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: [],
});
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: [],
});
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<Breakpoint>([
{ 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: [],
});
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 },
]);
});
test("insert missing tokens and use merged breakpoint ids", () => {
const breakpoints = toMap<Breakpoint>([{ id: "base", label: "base" }]);
const styleSources = toMap<StyleSource>([
{ id: "token1", type: "token", name: "oldLabel" },
]);
const data = getWebstudioDataStub({ breakpoints, styleSources });
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
breakpoints: [{ id: "new_base", label: "Base" }],
styleSources: [
{ id: "token1", type: "token", name: "newLabel" },
{ id: "token2", type: "token", name: "myToken" },
],
styles: [
{
styleSourceId: "token1",
breakpointId: "new_base",
property: "color",
value: { type: "keyword", value: "red" },
},
{
styleSourceId: "token2",
breakpointId: "new_base",
property: "color",
value: { type: "keyword", value: "green" },
},
],
},
availableVariables: [],
});
expect(Array.from(data.styleSources.values())).toEqual([
{ id: "token1", type: "token", name: "oldLabel" },
{ id: "token2", type: "token", name: "myToken" },
]);
expect(Array.from(data.styles.values())).toEqual([
{
styleSourceId: "token2",
breakpointId: "base",
property: "color",
value: { type: "keyword", value: "green" },
},
]);
});
test("insert local styles with new ids and use merged breakpoint ids", () => {
const breakpoints = toMap<Breakpoint>([{ 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: [],
});
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<Breakpoint>([{ 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: [],
});
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" },
},
]);
});
});
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"></$.Bold>
</$.Paragraph>
</$.Box>
</$.Body>
);
$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"></$.Paragraph>
</$.Body>
);
$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</$.Body>);
$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"></$.Bold>
</$.Paragraph>
</$.Body>
);
$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"></$.Box>
<$.Paragraph ws:id="paragraphId">{expression`"bla"`}</$.Paragraph>
<$.Box ws:id="box2Id"></$.Box>
</$.Body>
);
$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"></$.Paragraph>
</$.Body>
);
$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">
<$.Box ws:id="spanId" tag="span"></$.Box>
</$.Paragraph>
</$.Body>
);
$instances.set(instances);
selectInstance(["boxId", "paragraphId", "bodyId"]);
expect(findClosestInsertable(newBoxFragment)).toEqual({
parentSelector: ["paragraphId", "bodyId"],
position: 0,
});
});
test("forbids inserting into :root", () => {
const { instances } = renderData(<$.Body ws:id="bodyId"></$.Body>);
$instances.set(instances);
selectInstance([":root"]);
expect(findClosestInsertable(newBoxFragment)).toEqual(undefined);
});
test("allow inserting into collection item", () => {
const { instances } = renderData(
<$.Body ws:id="bodyId">
<ws.collection ws:id="collectionId">
<$.Box ws:id="boxId"></$.Box>
</ws.collection>
</$.Body>
);
$instances.set(instances);
selectInstance(["collectionId[1]", "collectionId", "bodyId"]);
expect(findClosestInsertable(newBoxFragment)).toEqual({
parentSelector: ["collectionId", "bodyId"],
position: "end",
});
});
test("forbid inserting list item in body", () => {
const { instances } = renderData(<$.Body ws:id="bodyId"></$.Body>);
$instances.set(instances);
selectInstance(["bodyId"]);
const newListItemFragment = createFragment({
children: [{ type: "id", value: "newListItemId" }],
instances: [
{
type: "instance",
id: "newListItemId",
component: "ListItem",
children: [],
},
],
});
expect(findClosestInsertable(newListItemFragment)).toEqual(undefined);
});
});