Files
webstudio/apps/builder/app/shared/instance-utils.test.ts
T
Bogdan Chadkin d060fd552a refactor: insert fragments with multiple roots (#3582)
Ref https://github.com/webstudio-is/webstudio/issues/2647

Here fixed the issue with detecting root as first instance in the
fragment. The idea of this utility is to not rely on any root but
instead insert with new ids all instances outside of portals/components.
Roots semantics is left for outer utilities.
2024-06-18 02:19:02 +03:00

3116 lines
86 KiB
TypeScript

import { enableMapSet } from "immer";
import { describe, test, expect, beforeEach } from "@jest/globals";
import {
portalComponent,
collectionComponent,
type WsComponentMeta,
} from "@webstudio-is/react-sdk";
import * as defaultMetas from "@webstudio-is/sdk-components-react/metas";
import type {
Asset,
Breakpoint,
DataSource,
Instance,
Instances,
Prop,
Resource,
StyleDecl,
StyleDeclKey,
StyleSource,
WebstudioData,
} from "@webstudio-is/sdk";
import { encodeDataSourceVariable } from "@webstudio-is/sdk";
import type { StyleProperty, StyleValue } from "@webstudio-is/css-engine";
import {
computeInstancesConstraints,
findClosestDroppableComponentIndex,
findClosestDroppableTarget,
findClosestEditableInstanceSelector,
insertTemplateData,
type InsertConstraints,
deleteInstanceMutable,
extractWebstudioFragment,
insertWebstudioFragmentCopy,
reparentInstance,
getWebstudioData,
insertInstanceChildrenMutable,
} from "./instance-utils";
import {
$assets,
$breakpoints,
$dataSources,
$instances,
$pages,
$project,
$props,
$styleSourceSelections,
$styleSources,
$styles,
$registeredComponentMetas,
$resources,
} from "./nano-states";
import { registerContainers } from "./sync";
import type { Project } from "@webstudio-is/project";
import { createDefaultPages } from "@webstudio-is/project-build";
enableMapSet();
registerContainers();
$pages.set(createDefaultPages({ rootInstanceId: "", systemDataSourceId: "" }));
const defaultMetasMap = new Map(Object.entries(defaultMetas));
$registeredComponentMetas.set(defaultMetasMap);
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 createInstancePair = (
id: Instance["id"],
component: string,
children: Instance["children"]
): [Instance["id"], Instance] => {
return [id, { 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 createProp = (instanceId: string, id: string, name: string): Prop => ({
id,
instanceId,
name,
type: "string",
value: id,
});
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: {} },
};
};
const emptyInsertConstraints: InsertConstraints = {
requiredAncestors: new Set(),
invalidAncestors: new Set(),
};
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("compute instances constraints", () => {
const base = {
type: "container",
label: "",
icon: "",
} as const;
test("combine required ancestors excluding already resolved ones", () => {
const metas = new Map<string, WsComponentMeta>([
["Button", { ...base, requiredAncestors: ["Form"] }],
["Checkbox", { ...base, requiredAncestors: ["Form", "Label"] }],
["Label", { ...base, requiredAncestors: ["Body"] }],
]);
// button
// label
// checkbox
const instances = new Map<Instance["id"], Instance>([
createInstancePair("button", "Button", []),
createInstancePair("label", "Label", [{ type: "id", value: "checkbox" }]),
createInstancePair("checkbox", "Checkbox", []),
]);
expect(
computeInstancesConstraints(metas, instances, ["button", "label"])
).toEqual({
requiredAncestors: new Set(["Body", "Form"]),
invalidAncestors: new Set(),
});
});
test("combine invalid ancestors of all instances", () => {
const metas = new Map<string, WsComponentMeta>([
["Button", { ...base, invalidAncestors: ["Button"] }],
["Form", { ...base, invalidAncestors: ["Button", "Form"] }],
]);
// form
// button
const instances = new Map<Instance["id"], Instance>([
createInstancePair("form", "Form", [{ type: "id", value: "button" }]),
createInstancePair("button", "Button", []),
]);
expect(computeInstancesConstraints(metas, instances, ["form"])).toEqual({
requiredAncestors: new Set(),
invalidAncestors: new Set(["Button", "Form"]),
});
});
});
describe("find closest droppable component index", () => {
test("finds container", () => {
expect(
findClosestDroppableComponentIndex({
metas: createFakeComponentMetas({}),
constraints: emptyInsertConstraints,
instances: new Map<Instance["id"], Instance>([
createInstancePair("body", "Body", [{ type: "id", value: "box" }]),
createInstancePair("box", "Box", []),
]),
instanceSelector: ["box", "body"],
})
).toEqual(0);
});
test("skips non containers", () => {
expect(
findClosestDroppableComponentIndex({
metas: createFakeComponentMetas({}),
constraints: emptyInsertConstraints,
instances: new Map<Instance["id"], Instance>([
createInstancePair("body", "Body", [{ type: "id", value: "box" }]),
createInstancePair("box", "Box", [{ type: "id", value: "text" }]),
createInstancePair("text", "Text", [{ type: "id", value: "italic" }]),
createInstancePair("italic", "Italic", [
{ type: "id", value: "bold" },
]),
createInstancePair("bold", "Bold", []),
]),
instanceSelector: ["bold", "italic", "text", "box", "body"],
})
).toEqual(2);
});
test("considers invalid ancestors", () => {
expect(
findClosestDroppableComponentIndex({
metas: createFakeComponentMetas({}),
constraints: {
requiredAncestors: new Set(),
invalidAncestors: new Set(["Item"]),
},
instances: new Map<Instance["id"], Instance>([
createInstancePair("body", "Body", [{ type: "id", value: "item" }]),
createInstancePair("item", "Item", [{ type: "id", value: "box" }]),
createInstancePair("box", "Box", []),
]),
instanceSelector: ["box", "item", "body"],
})
).toEqual(2);
});
test("requires some ancestor", () => {
expect(
findClosestDroppableComponentIndex({
metas: createFakeComponentMetas({}),
constraints: {
requiredAncestors: new Set(["Form"]),
invalidAncestors: new Set(),
},
instances: new Map<Instance["id"], Instance>([
createInstancePair("body", "Body", [{ type: "id", value: "box" }]),
createInstancePair("box", "Box", []),
]),
instanceSelector: ["box", "body"],
})
).toEqual(-1);
expect(
findClosestDroppableComponentIndex({
metas: createFakeComponentMetas({}),
constraints: {
requiredAncestors: new Set(["Form"]),
invalidAncestors: new Set(),
},
instances: new Map<Instance["id"], Instance>([
createInstancePair("body", "Body", [{ type: "id", value: "form" }]),
createInstancePair("form", "Form", [{ type: "id", value: "box" }]),
createInstancePair("box", "Box", []),
]),
instanceSelector: ["box", "form", "body"],
})
).toEqual(0);
});
test("considers both required and invalid ancestors", () => {
expect(
findClosestDroppableComponentIndex({
metas: createFakeComponentMetas({}),
constraints: {
requiredAncestors: new Set(["Form"]),
invalidAncestors: new Set(["Box"]),
},
instances: new Map<Instance["id"], Instance>([
createInstancePair("body", "Body", [{ type: "id", value: "form" }]),
createInstancePair("form", "Form", [{ type: "id", value: "box" }]),
createInstancePair("box", "Box", [{ type: "id", value: "div" }]),
createInstancePair("div", "Div", []),
]),
instanceSelector: ["div", "box", "form", "body"],
})
).toEqual(2);
expect(
findClosestDroppableComponentIndex({
metas: createFakeComponentMetas({}),
constraints: {
requiredAncestors: new Set(["Form"]),
invalidAncestors: new Set(["Box"]),
},
instances: new Map<Instance["id"], Instance>([
createInstancePair("body", "Body", [{ type: "id", value: "form" }]),
createInstancePair("form", "Form", [{ type: "id", value: "box" }]),
createInstancePair("box", "Box", [{ type: "id", value: "div" }]),
createInstancePair("div", "Div", []),
]),
instanceSelector: ["div", "form", "box", "body"],
})
).toEqual(-1);
});
});
describe("find closest droppable target", () => {
const createInstancePair = (
id: Instance["id"],
component: string,
children: Instance["children"]
): [Instance["id"], Instance] => {
return [id, { type: "instance", id, component, children }];
};
test("puts in the end if closest instance is container", () => {
const instances = new Map([
createInstancePair("body", "Body", [{ type: "id", value: "box" }]),
createInstancePair("box", "Box", [{ type: "id", value: "paragraph" }]),
createInstancePair("paragraph", "Paragraph", [
{ type: "id", value: "bold" },
]),
]);
expect(
findClosestDroppableTarget(
defaultMetasMap,
instances,
["box", "body"],
emptyInsertConstraints
)
).toEqual({
parentSelector: ["box", "body"],
position: "end",
});
});
test("puts in the end of root instance", () => {
const instances = new Map([
createInstancePair("body", "Body", [{ type: "id", value: "box" }]),
createInstancePair("box", "Box", [{ type: "id", value: "paragraph" }]),
]);
expect(
findClosestDroppableTarget(
defaultMetasMap,
instances,
["body"],
emptyInsertConstraints
)
).toEqual({
parentSelector: ["body"],
position: "end",
});
});
test("finds closest container and puts after its child within selection", () => {
const instances = new Map([
createInstancePair("body", "Body", [{ type: "id", value: "paragraph" }]),
createInstancePair("paragraph", "Paragraph", [
{ type: "id", value: "bold" },
]),
createInstancePair("bold", "Bold", []),
]);
expect(
findClosestDroppableTarget(
defaultMetasMap,
instances,
["bold", "paragraph", "body"],
emptyInsertConstraints
)
).toEqual({
parentSelector: ["paragraph", "body"],
position: 1,
});
});
test("finds closest container that doesn't have a direct text child", () => {
const instances = new Map([
createInstancePair("body", "Body", [
{ type: "id", value: "box1" },
{ type: "id", value: "paragraph" },
{ type: "id", value: "box2" },
]),
createInstancePair("paragraph", "Paragraph", [
{ type: "text", value: "some text" },
]),
createInstancePair("box1", "Box1", []),
createInstancePair("box2", "Box2", []),
]);
expect(
findClosestDroppableTarget(
defaultMetasMap,
instances,
["paragraph", "body"],
emptyInsertConstraints
)
).toEqual({
parentSelector: ["body"],
position: 2,
});
});
test("finds closest container that doesn't have an expression as a child", () => {
const instances = new Map([
createInstancePair("body", "Body", [
{ type: "id", value: "box1" },
{ type: "id", value: "paragraph" },
{ type: "id", value: "box2" },
]),
createInstancePair("paragraph", "Paragraph", [
{ type: "expression", value: '"bla"' },
]),
createInstancePair("box1", "Box1", []),
createInstancePair("box2", "Box2", []),
]);
expect(
findClosestDroppableTarget(
defaultMetasMap,
instances,
["paragraph", "body"],
emptyInsertConstraints
)
).toEqual({
parentSelector: ["body"],
position: 2,
});
});
});
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",
},
])
);
});
});
test("insert template data with instances", () => {
$instances.set(toMap([createInstance("body", "Body", [])]));
$styleSourceSelections.set(new Map());
$styleSources.set(toMap([{ type: "token", id: "1", name: "Zero" }]));
$styles.set(
new Map([
[
"1:base:color:",
{
breakpointId: "base",
styleSourceId: "1",
property: "color",
value: { type: "keyword", value: "red" },
},
],
])
);
insertTemplateData(
{
children: [{ type: "id", value: "box" }],
instances: [createInstance("box", "Box", [])],
props: [],
dataSources: [],
styleSourceSelections: [],
styleSources: [],
styles: [],
assets: [],
resources: [],
breakpoints: [],
},
{ parentSelector: ["body"], position: "end" }
);
expect($instances.get()).toEqual(
toMap<Instance>([
createInstance("body", "Body", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
])
);
});
test("insert template inside text-only instance should wrap the text into Text instance", () => {
$instances.set(
toMap([
createInstance("body", "Body", [
{
type: "id",
value: "heading",
},
]),
createInstance("heading", "Heading", [
{ type: "text", value: "Heading text" },
]),
])
);
insertTemplateData(
{
children: [{ type: "id", value: "box" }],
instances: [createInstance("box", "Box", [])],
props: [],
dataSources: [],
styleSourceSelections: [],
styleSources: [],
styles: [],
assets: [],
resources: [],
breakpoints: [],
},
{ parentSelector: ["heading", "body"], position: "end" }
);
expect($instances.get()).toEqual(
toMap<Instance>([
createInstance("body", "Body", [
{
type: "id",
value: "heading",
},
]),
createInstance("heading", "Heading", [
{
type: "id",
value: expect.not.stringMatching("text") as unknown as string,
},
{ type: "id", value: "box" },
]),
createInstance(
expect.not.stringMatching("text") as unknown as string,
"Text",
[{ type: "text", value: "Heading text" }]
),
createInstance("box", "Box", []),
])
);
});
test("insert template data with only new style sources", () => {
$instances.set(new Map([createInstancePair("body", "Body", [])]));
$styleSourceSelections.set(new Map());
$styleSources.set(new Map([["1", { type: "token", id: "1", name: "Zero" }]]));
$styles.set(
new Map([
[
"1:base:color:",
{
breakpointId: "base",
styleSourceId: "1",
property: "color",
value: { type: "keyword", value: "red" },
},
],
])
);
insertTemplateData(
{
children: [{ type: "id", value: "box1" }],
instances: [
{ type: "instance", id: "box1", component: "Box", children: [] },
],
props: [],
dataSources: [],
styleSourceSelections: [{ instanceId: "box1", values: ["1", "2"] }],
styleSources: [
{ type: "token", id: "1", name: "One" },
{ type: "token", id: "2", name: "Two" },
],
styles: [
{
breakpointId: "base",
styleSourceId: "1",
property: "color",
value: { type: "keyword", value: "black" },
},
{
breakpointId: "base",
styleSourceId: "1",
property: "backgroundColor",
value: { type: "keyword", value: "purple" },
},
{
breakpointId: "base",
styleSourceId: "2",
property: "color",
value: { type: "keyword", value: "green" },
},
],
assets: [],
resources: [],
breakpoints: [],
},
{ parentSelector: ["body"], position: "end" }
);
expect($styleSourceSelections.get()).toEqual(
new Map([["box1", { instanceId: "box1", values: ["1", "2"] }]])
);
expect($styleSources.get()).toEqual(
new Map([
["1", { type: "token", id: "1", name: "Zero" }],
["2", { type: "token", id: "2", name: "Two" }],
])
);
expect($styles.get()).toEqual(
new Map([
[
"1:base:color:",
{
breakpointId: "base",
styleSourceId: "1",
property: "color",
value: { type: "keyword", value: "red" },
},
],
[
"2:base:color:",
{
breakpointId: "base",
styleSourceId: "2",
property: "color",
value: { type: "keyword", value: "green" },
},
],
])
);
});
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", []),
])
);
});
});
const getWebstudioDataStub = (
data?: Partial<WebstudioData>
): WebstudioData => ({
pages: createDefaultPages({ rootInstanceId: "", systemDataSourceId: "" }),
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", () => {
// body
// box1
// box11
// box2
const instances = new Map([
createInstancePair("body", "Body", [
{ type: "id", value: "box1" },
{ type: "id", value: "box2" },
]),
createInstancePair("box1", "Box", [{ type: "id", value: "box11" }]),
createInstancePair("box11", "Box", []),
createInstancePair("box2", "Box", []),
]);
$registeredComponentMetas.set(createFakeComponentMetas({}));
const data = getWebstudioDataStub({ instances });
deleteInstanceMutable(data, ["box1", "body"]);
expect(data.instances).toEqual(
new Map([
createInstancePair("body", "Body", [{ type: "id", value: "box2" }]),
createInstancePair("box2", "Box", []),
])
);
});
test("prevent deleting root instance", () => {
// body
// box1
const instances = new Map([
createInstancePair("body", "Body", [{ type: "id", value: "box1" }]),
createInstancePair("box1", "Box", []),
]);
$registeredComponentMetas.set(createFakeComponentMetas({}));
const data = getWebstudioDataStub({ instances });
deleteInstanceMutable(data, ["body"]);
expect(data.instances).toEqual(
new Map([
createInstancePair("body", "Body", [{ type: "id", value: "box1" }]),
createInstancePair("box1", "Box", []),
])
);
});
test("delete instance from collection item", () => {
// body
// list
// box
const instances = new Map([
createInstancePair("body", "Body", [{ type: "id", value: "list" }]),
createInstancePair("list", collectionComponent, [
{ type: "id", value: "box" },
]),
createInstancePair("box", "Box", []),
]);
$registeredComponentMetas.set(createFakeComponentMetas({}));
const data = getWebstudioDataStub({ instances });
deleteInstanceMutable(data, ["box", "list[0]", "list", "body"]);
expect(data.instances).toEqual(
new Map([
createInstancePair("body", "Body", [{ type: "id", value: "list" }]),
createInstancePair("list", collectionComponent, []),
])
);
});
test("delete resource along with variable", () => {
const instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
]);
const resources = toMap<Resource>([
{
id: "resourceId",
name: "My Resource",
url: `""`,
method: "get",
headers: [],
},
]);
const dataSources = toMap<DataSource>([
{
id: "resourceVariableId",
scopeInstanceId: "box",
name: "My Resource Variable",
type: "resource",
resourceId: "resourceId",
},
]);
$registeredComponentMetas.set(createFakeComponentMetas({}));
const data = getWebstudioDataStub({ instances, resources, dataSources });
deleteInstanceMutable(data, ["box", "body"]);
expect(data.instances).toEqual(toMap([createInstance("body", "Body", [])]));
expect(data.dataSources).toEqual(new Map());
expect(data.resources).toEqual(new Map());
});
test("delete instance without meta", () => {
// body
// invalid
const instances = new Map([
createInstancePair("body", "Body", [{ type: "id", value: "invalid" }]),
createInstancePair("invalid", "Invalid", []),
]);
$registeredComponentMetas.set(new Map());
const data = getWebstudioDataStub({ instances });
deleteInstanceMutable(data, ["invalid", "body"]);
expect(data.instances).toEqual(
new Map([createInstancePair("body", "Body", [])])
);
});
});
describe("extract webstudio fragment", () => {
test("collect the instance by id and all its descendants including portal instances", () => {
// body
// bodyChild1
// slot
// slotChild
// bodyChild2
$instances.set(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "bodyChild1" },
{ type: "id", value: "bodyChild2" },
]),
createInstance("bodyChild1", "Box", [{ type: "id", value: "slot" }]),
createInstance("slot", "Slot", [{ type: "id", value: "slotChild" }]),
createInstance("slotChild", "Box", []),
createInstance("bodyChild2", "Box", []),
])
);
const { instances } = extractWebstudioFragment(
getWebstudioData(),
"bodyChild1"
);
expect(instances).toEqual([
createInstance("bodyChild1", "Box", [{ type: "id", value: "slot" }]),
createInstance("slot", "Slot", [{ type: "id", value: "slotChild" }]),
createInstance("slotChild", "Box", []),
]);
});
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 all props 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", []),
])
);
$props.set(
toMap([
createProp("body", "bodyProp", "data-body"),
createProp("box1", "box1Prop", "data-box1"),
createProp("box2", "box2Prop", "data-box2"),
])
);
const { props } = extractWebstudioFragment(getWebstudioData(), "box1");
expect(props).toEqual([
createProp("box1", "box1Prop", "data-box1"),
createProp("box2", "box2Prop", "data-box2"),
]);
});
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"),
]);
});
test("collect data sources used in expression props within instances", () => {
// body
// box1
// box2
$instances.set(
toMap([
createInstance("body", "Body", [{ type: "id", value: "box1" }]),
createInstance("box1", "Box", [{ type: "id", value: "box2" }]),
createInstance("box2", "Box", []),
])
);
$dataSources.set(
toMap([
{
id: "box1$state",
scopeInstanceId: "box1",
type: "variable",
name: "state",
value: { type: "string", value: "initial" },
},
])
);
$props.set(
toMap([
{
id: "box1$stateProp",
instanceId: "box1",
name: "state",
type: "expression",
value: "$ws$dataSource$box1$state",
},
{
id: "box2$stateProp",
instanceId: "box2",
name: "state",
type: "expression",
value: "$ws$dataSource$box1$state",
},
{
id: "box2$showProp",
instanceId: "box2",
name: "show",
type: "expression",
value: `$ws$dataSource$box1$state === 'initial'`,
},
{
id: "box2$trueProp",
instanceId: "box2",
name: "bool-prop",
type: "expression",
value: `true`,
},
])
);
const { props, dataSources } = extractWebstudioFragment(
getWebstudioData(),
"box2"
);
expect(dataSources).toEqual([
{
id: "box1$state",
scopeInstanceId: "box1",
type: "variable",
name: "state",
value: { type: "string", value: "initial" },
},
]);
expect(props).toEqual([
{
id: "box2$stateProp",
instanceId: "box2",
name: "state",
type: "expression",
value: "$ws$dataSource$box1$state",
},
{
id: "box2$showProp",
instanceId: "box2",
name: "show",
type: "expression",
value: `$ws$dataSource$box1$state === 'initial'`,
},
{
id: "box2$trueProp",
instanceId: "box2",
name: "bool-prop",
type: "expression",
value: `true`,
},
]);
});
test("collect data sources used in actions within instances", () => {
// body
// box1
// box2
$instances.set(
toMap([
createInstance("body", "Body", [{ type: "id", value: "box1" }]),
createInstance("box1", "Box", [{ type: "id", value: "box2" }]),
createInstance("box2", "Box", []),
])
);
$dataSources.set(
toMap([
{
id: "box1$state",
scopeInstanceId: "box1",
type: "variable",
name: "state",
value: { type: "string", value: "initial" },
},
{
id: "box2$state",
scopeInstanceId: "box2",
type: "variable",
name: "state",
value: { type: "string", value: "initial" },
},
])
);
$props.set(
toMap([
{
id: "box2$onChange1",
instanceId: "box2",
type: "action",
name: "onChange",
value: [
{
type: "execute",
args: ["value"],
code: `$ws$dataSource$box1$state = value`,
},
],
},
{
id: "box2$onChange2",
instanceId: "box2",
type: "action",
name: "onChange",
value: [
{
type: "execute",
args: ["value"],
code: `$ws$dataSource$box2$state = value`,
},
],
},
])
);
const { props, dataSources } = extractWebstudioFragment(
getWebstudioData(),
"box2"
);
expect(dataSources).toEqual([
{
id: "box1$state",
scopeInstanceId: "box1",
type: "variable",
name: "state",
value: { type: "string", value: "initial" },
},
{
id: "box2$state",
scopeInstanceId: "box2",
type: "variable",
name: "state",
value: { type: "string", value: "initial" },
},
]);
expect(props).toEqual([
{
id: "box2$onChange1",
instanceId: "box2",
type: "action",
name: "onChange",
value: [
{
args: ["value"],
code: "$ws$dataSource$box1$state = value",
type: "execute",
},
],
},
{
id: "box2$onChange2",
instanceId: "box2",
type: "action",
name: "onChange",
value: [
{
type: "execute",
args: ["value"],
code: `$ws$dataSource$box2$state = value`,
},
],
},
]);
});
test("collect data sources used in expression children within instances", () => {
// body
// box
$instances.set(
toMap([
createInstance("body", "Body", [{ type: "id", value: "box" }]),
createInstance("box", "Box", [
{
type: "expression",
value: "$ws$dataSource$body + $ws$dataSource$box",
},
]),
])
);
$dataSources.set(
toMap([
{
id: "body",
scopeInstanceId: "body",
type: "variable",
name: "body",
value: { type: "string", value: "body" },
},
{
id: "bodyUnused",
scopeInstanceId: "body",
type: "variable",
name: "bodyUnused",
value: { type: "string", value: "bodyUnused" },
},
{
id: "box",
scopeInstanceId: "box",
type: "variable",
name: "box",
value: { type: "string", value: "box" },
},
])
);
const { dataSources } = extractWebstudioFragment(getWebstudioData(), "box");
expect(dataSources).toEqual([
{
id: "body",
scopeInstanceId: "body",
type: "variable",
name: "body",
value: { type: "string", value: "body" },
},
{
id: "box",
scopeInstanceId: "box",
type: "variable",
name: "box",
value: { type: "string", value: "box" },
},
]);
});
test("collect resources within instances", () => {
// body
// box1
// box2
$instances.set(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "box1" },
{ type: "id", value: "box2" },
]),
createInstance("box1", "Box", []),
createInstance("box2", "Box", []),
])
);
$resources.set(
toMap([
{
id: "resource1",
name: "resource1",
url: `""`,
method: "get",
headers: [],
},
{
id: "resource2",
name: "resource2",
url: `""`,
method: "get",
headers: [],
},
{
id: "resource3",
name: "resource3",
url: `""`,
method: "get",
headers: [],
},
])
);
$dataSources.set(
toMap([
{
id: "body$state",
scopeInstanceId: "box1",
name: "data1",
type: "resource",
resourceId: "resource1",
},
{
id: "box1$state",
scopeInstanceId: "box1",
name: "data2",
type: "resource",
resourceId: "resource2",
},
{
id: "box2$state",
scopeInstanceId: "box2",
type: "resource",
name: "data3",
resourceId: "resource3",
},
])
);
const { resources, dataSources } = extractWebstudioFragment(
getWebstudioData(),
"box1"
);
expect(resources).toEqual([
{
id: "resource1",
name: "resource1",
url: `""`,
method: "get",
headers: [],
},
{
id: "resource2",
name: "resource2",
url: `""`,
method: "get",
headers: [],
},
]);
expect(dataSources).toEqual([
{
id: "body$state",
scopeInstanceId: "box1",
name: "data1",
type: "resource",
resourceId: "resource1",
},
{
id: "box1$state",
scopeInstanceId: "box1",
name: "data2",
type: "resource",
resourceId: "resource2",
},
]);
});
test("collect data sources used in resources", () => {
// body
// box
$instances.set(
toMap([
createInstance("body", "Body", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
])
);
$resources.set(
toMap([
{
id: "resourceId",
name: "resourceName",
url: `$ws$dataSource$bodyUrl`,
method: "post",
headers: [
{
name: "Authorization",
value: `"Token " + $ws$dataSource$bodyToken`,
},
],
body: `$ws$dataSource$boxBody`,
},
])
);
$dataSources.set(
toMap([
{
id: "boxData",
scopeInstanceId: "box",
name: "data",
type: "resource",
resourceId: "resourceId",
},
{
id: "boxBody",
scopeInstanceId: "box",
name: "token",
type: "variable",
value: { type: "string", value: "body" },
},
{
id: "bodyUrl",
scopeInstanceId: "body",
name: "url",
type: "variable",
value: { type: "string", value: "url" },
},
{
id: "bodyToken",
scopeInstanceId: "body",
name: "token",
type: "variable",
value: { type: "string", value: "token" },
},
{
id: "bodyAnotherUrl",
scopeInstanceId: "body",
name: "anotherUrl",
type: "variable",
value: { type: "string", value: "anotherUrl" },
},
])
);
const { resources, dataSources } = extractWebstudioFragment(
getWebstudioData(),
"box"
);
expect(resources).toEqual([
{
id: "resourceId",
name: "resourceName",
url: `$ws$dataSource$bodyUrl`,
method: "post",
headers: [
{
name: "Authorization",
value: `"Token " + $ws$dataSource$bodyToken`,
},
],
body: `$ws$dataSource$boxBody`,
},
]);
expect(dataSources).toEqual([
{
id: "boxData",
scopeInstanceId: "box",
name: "data",
type: "resource",
resourceId: "resourceId",
},
{
id: "boxBody",
scopeInstanceId: "box",
name: "token",
type: "variable",
value: { type: "string", value: "body" },
},
{
id: "bodyUrl",
scopeInstanceId: "body",
name: "url",
type: "variable",
value: { type: "string", value: "url" },
},
{
id: "bodyToken",
scopeInstanceId: "body",
name: "token",
type: "variable",
value: { type: "string", value: "token" },
},
]);
});
});
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")],
},
availableDataSources: new Set(),
});
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"),
],
},
availableDataSources: new Set(),
});
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,
},
],
},
availableDataSources: new Set(),
});
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" },
},
],
},
availableDataSources: new Set(),
});
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 instances", () => {
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
instances: [
{
type: "instance",
id: "box",
component: "Box",
children: [],
},
],
},
availableDataSources: new Set(),
});
expect(Array.from(data.instances.keys())).toEqual([
expect.not.stringMatching("box"),
]);
});
test("insert instances with portals", () => {
// portal
// fragment
// box
const instancesWithPortals: Instance[] = [
{
type: "instance",
id: "portal",
component: portalComponent,
children: [{ type: "id", value: "fragment" }],
},
{
type: "instance",
id: "fragment",
component: "Fragment",
children: [{ type: "id", value: "box" }],
},
{
type: "instance",
id: "box",
component: "Box",
children: [{ type: "text", value: "First" }],
},
];
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
instances: instancesWithPortals,
},
availableDataSources: new Set(),
});
expect(Array.from(data.instances.values())).toEqual([
{
type: "instance",
id: "fragment",
component: "Fragment",
children: [{ type: "id", value: "box" }],
},
{
type: "instance",
id: "box",
component: "Box",
children: [{ type: "text", value: "First" }],
},
{
type: "instance",
id: expect.not.stringMatching("portal"),
component: portalComponent,
children: [{ type: "id", value: "fragment" }],
},
]);
// change portal content and make sure it does not break
// when stale portal with same id is inserted
data.instances.delete("box");
data.instances.set("fragment", {
type: "instance",
id: "fragment",
component: "Fragment",
children: [],
});
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
instances: instancesWithPortals,
},
availableDataSources: new Set(),
});
expect(Array.from(data.instances.values())).toEqual([
{
type: "instance",
id: "fragment",
component: "Fragment",
children: [],
},
{
type: "instance",
id: expect.not.stringMatching("portal"),
component: portalComponent,
children: [{ type: "id", value: "fragment" }],
},
{
type: "instance",
id: expect.not.stringMatching("portal"),
component: portalComponent,
children: [{ type: "id", value: "fragment" }],
},
]);
});
test("insert props with new ids", () => {
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
props: [
{
id: "prop1",
instanceId: "body",
name: "myProp1",
type: "string",
value: "",
},
],
},
availableDataSources: new Set(),
});
expect(Array.from(data.props.values())).toEqual([
{
id: expect.not.stringMatching("prop1"),
instanceId: expect.not.stringMatching("body"),
name: "myProp1",
type: "string",
value: "",
},
]);
});
test("insert props from portals with old ids", () => {
const data = getWebstudioDataStub();
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: [],
},
],
props: [
{
id: "prop1",
instanceId: "fragment",
name: "myProp1",
type: "string",
value: "",
},
],
},
availableDataSources: new Set(),
});
expect(Array.from(data.props.values())).toEqual([
{
id: "prop1",
instanceId: "fragment",
name: "myProp1",
type: "string",
value: "",
},
]);
});
test("insert data sources with new ids", () => {
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
dataSources: [
{
id: "variableId",
scopeInstanceId: "body",
type: "variable",
name: "myVariable",
value: { type: "string", value: "" },
},
],
instances: [
createInstance("body", "Body", [
{ type: "expression", value: "$ws$dataSource$variableId" },
]),
],
props: [
{
id: "expressionId",
instanceId: "body",
name: "myProp1",
type: "expression",
value: "$ws$dataSource$variableId",
},
{
id: "actionId",
instanceId: "body",
name: "myProp2",
type: "action",
value: [
{
type: "execute",
args: [],
code: `$ws$dataSource$variableId = ""`,
},
],
},
{
id: "parameterId",
instanceId: "body",
name: "myProp3",
type: "parameter",
value: "variableId",
},
],
},
availableDataSources: new Set(),
});
const [newVariableId] = data.dataSources.keys();
const [newInstanceId] = data.instances.keys();
expect(newVariableId).not.toEqual("variableId");
expect(Array.from(data.dataSources.values())).toEqual([
{
id: newVariableId,
scopeInstanceId: newInstanceId,
type: "variable",
name: "myVariable",
value: { type: "string", value: "" },
},
]);
expect(Array.from(data.instances.values())).toEqual([
createInstance(newInstanceId, "Body", [
{ type: "expression", value: encodeDataSourceVariable(newVariableId) },
]),
]);
expect(Array.from(data.props.values())).toEqual([
{
id: expect.not.stringMatching("expressionId"),
instanceId: newInstanceId,
name: "myProp1",
type: "expression",
value: encodeDataSourceVariable(newVariableId),
},
{
id: expect.not.stringMatching("actionId"),
instanceId: newInstanceId,
name: "myProp2",
type: "action",
value: [
{
type: "execute",
args: [],
code: `${encodeDataSourceVariable(newVariableId)} = ""`,
},
],
},
{
id: expect.not.stringMatching("parameterId"),
instanceId: newInstanceId,
name: "myProp3",
type: "parameter",
value: newVariableId,
},
]);
});
test("inline data sources when not available in scope", () => {
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
dataSources: [
{
id: "outsideVariableId",
scopeInstanceId: "outsideInstanceId",
type: "variable",
name: "myOutsideVariable",
value: { type: "string", value: "outside" },
},
{
id: "insideVariableId",
scopeInstanceId: "insideInstanceId",
type: "variable",
name: "myInsideVariable",
value: { type: "string", value: "inside" },
},
],
instances: [
createInstance("body", "Body", [
{
type: "expression",
value:
"$ws$dataSource$outsideVariableId + $ws$dataSource$insideVariableId",
},
]),
],
props: [
{
id: "expressionId",
instanceId: "body",
name: "myProp1",
type: "expression",
value:
"$ws$dataSource$outsideVariableId + $ws$dataSource$insideVariableId",
},
{
id: "actionId",
instanceId: "body",
name: "myProp2",
type: "action",
value: [
{
type: "execute",
args: [],
code: `$ws$dataSource$outsideVariableId = "outside"`,
},
{
type: "execute",
args: [],
code: `$ws$dataSource$insideVariableId = "inside"`,
},
],
},
],
},
availableDataSources: new Set(["insideVariableId"]),
});
const [newInstanceId] = data.instances.keys();
expect(data.dataSources).toEqual(new Map());
expect(Array.from(data.instances.values())).toEqual([
createInstance(newInstanceId, "Body", [
{
type: "expression",
value: `"outside" + $ws$dataSource$insideVariableId`,
},
]),
]);
expect(Array.from(data.props.values())).toEqual([
{
id: expect.not.stringMatching("expressionId"),
instanceId: newInstanceId,
name: "myProp1",
type: "expression",
value: `"outside" + $ws$dataSource$insideVariableId`,
},
{
id: expect.not.stringMatching("actionId"),
instanceId: newInstanceId,
name: "myProp2",
type: "action",
value: [
{
type: "execute",
args: [],
code: `$ws$dataSource$insideVariableId = "inside"`,
},
],
},
]);
});
test("insert data sources from portals with old ids", () => {
const data = getWebstudioDataStub();
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: "expression", value: "$ws$dataSource$variableId" },
],
},
],
dataSources: [
{
id: "variableId",
scopeInstanceId: "fragment",
type: "variable",
name: "myVariable",
value: { type: "string", value: "" },
},
],
props: [
{
id: "expressionId",
instanceId: "fragment",
name: "myProp1",
type: "expression",
value: "$ws$dataSource$variableId",
},
{
id: "actionId",
instanceId: "fragment",
name: "myProp2",
type: "action",
value: [
{
type: "execute",
args: [],
code: `$ws$dataSource$variableId = ""`,
},
],
},
{
id: "parameterId",
instanceId: "fragment",
name: "myProp3",
type: "parameter",
value: "variableId",
},
],
},
availableDataSources: new Set(),
});
expect(Array.from(data.dataSources.values())).toEqual([
{
id: "variableId",
scopeInstanceId: "fragment",
type: "variable",
name: "myVariable",
value: { type: "string", value: "" },
},
]);
expect(data.instances.get("fragment")).toEqual({
type: "instance",
id: "fragment",
component: "Fragment",
children: [{ type: "expression", value: "$ws$dataSource$variableId" }],
});
expect(Array.from(data.props.values())).toEqual([
{
id: "expressionId",
instanceId: "fragment",
name: "myProp1",
type: "expression",
value: `$ws$dataSource$variableId`,
},
{
id: "actionId",
instanceId: "fragment",
name: "myProp2",
type: "action",
value: [
{
type: "execute",
args: [],
code: `$ws$dataSource$variableId = ""`,
},
],
},
{
id: "parameterId",
instanceId: "fragment",
name: "myProp3",
type: "parameter",
value: `variableId`,
},
]);
});
test("inline data sources from portals when not available in scope", () => {
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
// portal
// fragment
instances: [
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", [
{
type: "expression",
value:
"$ws$dataSource$outsideVariableId + $ws$dataSource$insideVariableId",
},
]),
],
dataSources: [
{
id: "outsideVariableId",
scopeInstanceId: "outsideInstanceId",
type: "variable",
name: "myOutsideVariable",
value: { type: "string", value: "outside" },
},
{
id: "insideVariableId",
scopeInstanceId: "insideInstanceId",
type: "variable",
name: "myInsideVariable",
value: { type: "string", value: "inside" },
},
],
props: [
{
id: "expressionId",
instanceId: "fragment",
name: "myProp1",
type: "expression",
value:
"$ws$dataSource$outsideVariableId + $ws$dataSource$insideVariableId",
},
{
id: "actionId",
instanceId: "fragment",
name: "myProp2",
type: "action",
value: [
{
type: "execute",
args: [],
code: `$ws$dataSource$outsideVariableId = "outside"`,
},
{
type: "execute",
args: [],
code: `$ws$dataSource$insideVariableId = "inside"`,
},
],
},
],
},
availableDataSources: new Set(["insideVariableId"]),
});
expect(data.dataSources).toEqual(new Map());
expect(data.instances.get("fragment")).toEqual(
createInstance("fragment", "Fragment", [
{
type: "expression",
value: `"outside" + $ws$dataSource$insideVariableId`,
},
])
);
expect(Array.from(data.props.values())).toEqual([
{
id: "expressionId",
instanceId: "fragment",
name: "myProp1",
type: "expression",
value: `"outside" + $ws$dataSource$insideVariableId`,
},
{
id: "actionId",
instanceId: "fragment",
name: "myProp2",
type: "action",
value: [
{
type: "execute",
args: [],
code: `$ws$dataSource$insideVariableId = "inside"`,
},
],
},
]);
});
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" },
},
],
},
availableDataSources: new Set(),
});
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" },
},
],
},
availableDataSources: new Set(),
});
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("insert resources with new ids", () => {
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
resources: [
{
id: "resourceId",
name: "",
url: `$ws$dataSource$paramVariableId`,
method: "post",
headers: [
{ name: "auth", value: "$ws$dataSource$paramVariableId" },
],
body: `$ws$dataSource$paramVariableId`,
},
],
dataSources: [
{
id: "paramVariableId",
scopeInstanceId: "body",
name: "myParam",
type: "variable",
value: { type: "string", value: "myParam" },
},
{
id: "resourceVariableId",
scopeInstanceId: "body",
name: "myResource",
type: "resource",
resourceId: "resourceId",
},
],
},
availableDataSources: new Set(),
});
const [newInstanceId] = data.instances.keys();
const [newParamId, newResourceVariableId] = data.dataSources.keys();
const [newResourceId] = data.resources.keys();
expect(newResourceId).not.toEqual("resourceId");
expect(Array.from(data.dataSources.values())).toEqual([
{
id: newParamId,
scopeInstanceId: newInstanceId,
name: "myParam",
type: "variable",
value: { type: "string", value: "myParam" },
},
{
id: newResourceVariableId,
scopeInstanceId: newInstanceId,
name: "myResource",
type: "resource",
resourceId: newResourceId,
},
]);
expect(Array.from(data.resources.values())).toEqual([
{
id: newResourceId,
name: "",
url: encodeDataSourceVariable(newParamId),
method: "post",
headers: [
{ name: "auth", value: encodeDataSourceVariable(newParamId) },
],
body: encodeDataSourceVariable(newParamId),
},
]);
});
test("inline data sources into resources when not available in the scope", () => {
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
resources: [
{
id: "resourceId",
name: "",
url: `$ws$dataSource$paramVariableId`,
method: "post",
headers: [
{ name: "auth", value: "$ws$dataSource$paramVariableId" },
],
body: `$ws$dataSource$paramVariableId`,
},
],
dataSources: [
{
id: "paramVariableId",
scopeInstanceId: "outside",
name: "myParam",
type: "variable",
value: { type: "string", value: "myParam" },
},
{
id: "variableId",
scopeInstanceId: "body",
name: "myResource",
type: "resource",
resourceId: "resourceId",
},
],
},
availableDataSources: new Set(),
});
const [newResourceId] = data.resources.keys();
expect(Array.from(data.resources.values())).toEqual([
{
id: newResourceId,
name: "",
url: `"myParam"`,
method: "post",
headers: [{ name: "auth", value: `"myParam"` }],
body: `"myParam"`,
},
]);
});
test("inline resource variables when not available in scope", () => {
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
resources: [
{
id: "resourceId",
name: "",
url: `""`,
method: "get",
headers: [],
},
],
dataSources: [
{
id: "variableId",
scopeInstanceId: "outside",
name: "myResource",
type: "resource",
resourceId: "resourceId",
},
],
props: [
{
id: "expressionId",
instanceId: "body",
name: "myProp",
type: "expression",
value: "$ws$dataSource$variableId",
},
],
},
availableDataSources: new Set(),
});
const [newInstanceId] = data.instances.keys();
expect(Array.from(data.props.values())).toEqual([
{
id: expect.not.stringMatching("expressionId"),
instanceId: newInstanceId,
name: "myProp",
type: "expression",
value: "{}",
},
]);
});
test("insert resources from portals with old ids", () => {
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
// portal
// fragment
instances: [
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
],
resources: [
{
id: "resourceId",
name: "",
url: `""`,
method: "get",
headers: [],
},
],
dataSources: [
{
id: "variableId",
scopeInstanceId: "fragment",
name: "myVariable",
type: "resource",
resourceId: "resourceId",
},
],
},
availableDataSources: new Set(),
});
expect(Array.from(data.dataSources.values())).toEqual([
{
id: "variableId",
scopeInstanceId: "fragment",
name: "myVariable",
type: "resource",
resourceId: "resourceId",
},
]);
expect(Array.from(data.resources.values())).toEqual([
{
id: "resourceId",
name: "",
url: `""`,
method: "get",
headers: [],
},
]);
});
test("inline data sources into resources from portals when not available in scope", () => {
const data = getWebstudioDataStub();
insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
// portal
// fragment
instances: [
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
],
resources: [
{
id: "resourceId",
name: "",
url: `$ws$dataSource$paramVariableId`,
method: "post",
headers: [
{ name: "auth", value: "$ws$dataSource$paramVariableId" },
],
body: `$ws$dataSource$paramVariableId`,
},
],
dataSources: [
{
id: "paramVariableId",
scopeInstanceId: "outside",
name: "myParam",
type: "variable",
value: { type: "string", value: "myParam" },
},
{
id: "variableId",
scopeInstanceId: "fragment",
name: "myResource",
type: "resource",
resourceId: "resourceId",
},
],
},
availableDataSources: new Set(),
});
const [newResourceId] = data.resources.keys();
expect(Array.from(data.resources.values())).toEqual([
{
id: newResourceId,
name: "",
url: `"myParam"`,
method: "post",
headers: [{ name: "auth", value: `"myParam"` }],
body: `"myParam"`,
},
]);
});
test("insert instances with multiple roots", () => {
const data = getWebstudioDataStub();
const { newInstanceIds } = insertWebstudioFragmentCopy({
data,
fragment: {
...emptyFragment,
// body1
// box1
// body2
// box2
// explicily define box first and then body
// to check first instance is not used as root
instances: [
createInstance("box1", "Box", []),
createInstance("body1", "Body", [{ type: "id", value: "box1" }]),
createInstance("body2", "Body", [{ type: "id", value: "box2" }]),
createInstance("box2", "Box", []),
],
resources: [],
dataSources: [],
},
availableDataSources: new Set(),
});
expect(data.instances.size).toEqual(4);
expect(newInstanceIds.size).toEqual(4);
});
});