Files
Oleg Isonen d339dc55cf feat: Add token conflict detection and resolution for marketplace templates (#5609)
- Add Token class and token() helper in template package for JSX token
support
- Refactor insertFragmentWithConflictResolution to
detectFragmentTokenConflicts
- Refactor insertPageWithConflictResolution to detectPageTokenConflicts
- Move dialog display to callers for better testability
- Add conflictResolution parameter to page-utils
- Add comprehensive tests for token functionality and conflict detection
- Fix bug: remove .values() call on arrays in breakpoint handling
2026-02-05 22:34:17 +01:00

555 lines
16 KiB
TypeScript

import { describe, test, expect, vi, beforeAll } from "vitest";
import { enableMapSet } from "immer";
import type {
Instance,
Instances,
Prop,
Props,
DataSource,
DataSources,
} from "@webstudio-is/sdk";
import {
encodeDataSourceVariable,
collectionComponent,
coreMetas,
portalComponent,
} from "@webstudio-is/sdk";
import type { Project } from "@webstudio-is/project";
import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas";
import { registerContainers } from "../sync/sync-stores";
import {
$instances,
$dataSources,
$pages,
$project,
$props,
$registeredComponentMetas,
} from "../nano-states";
import { instanceText } from "./plugin-instance";
import { createDefaultPages } from "@webstudio-is/project-build";
import { $awareness, selectInstance } from "../awareness";
import * as instanceUtils from "../instance-utils";
const expectString = expect.any(String) as unknown as string;
enableMapSet();
registerContainers();
// Mock detectFragmentTokenConflicts to always return no conflicts
beforeAll(() => {
vi.spyOn(instanceUtils, "detectFragmentTokenConflicts").mockReturnValue([]);
});
$registeredComponentMetas.set(
new Map(Object.entries({ ...baseComponentMetas, ...coreMetas }))
);
$project.set({ id: "my-project" } as Project);
$pages.set(
createDefaultPages({
homePageId: "home-page",
rootInstanceId: "body0",
})
);
$awareness.set({ pageId: "home-page" });
const createInstance = (
id: Instance["id"],
component: string,
children: Instance["children"]
): Instance => {
return { type: "instance", id, component, children };
};
const getIdValuePair = <T extends { id: string }>(item: T) =>
[item.id, item] as const;
const toMap = <T extends { id: string }>(list: T[]) =>
new Map(list.map(getIdValuePair));
const getMapDifference = <Type extends Map<unknown, unknown>>(
left: Type,
right: Type
): Type => {
const leftSet = new Set(left.keys());
const rightSet = new Set(right.keys());
const difference = new Map() as Type;
for (const [key, value] of left) {
if (rightSet.has(key) === false) {
difference.set(key, value);
}
}
for (const [key, value] of right) {
if (leftSet.has(key) === false) {
difference.set(key, value);
}
}
return difference;
};
describe("paste target", () => {
// body0
// box1
// box2
const instances: Instances = toMap([
createInstance("body0", "Body", [
{ type: "id", value: "box1" },
{ type: "id", value: "box2" },
]),
createInstance("box1", "Box", []),
createInstance("box2", "Box", []),
] satisfies Instance[]);
test("is inside selected instance", async () => {
$instances.set(instances);
selectInstance(["box1", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["box2", "body0"]);
await instanceText.onPaste?.(clipboardData);
const instancesDifference = getMapDifference(instances, $instances.get());
const [newBox1] = instancesDifference.keys();
expect($instances.get().get("box2")).toEqual(
createInstance("box2", "Box", [{ type: "id", value: newBox1 }])
);
expect(instancesDifference).toEqual(
toMap([createInstance(newBox1, "Box", [])])
);
});
test("is after selected instance when same as copied", async () => {
$instances.set(instances);
selectInstance(["box1", "body0"]);
await instanceText.onPaste?.(instanceText.onCopy?.() ?? "");
const instancesDifference = getMapDifference(instances, $instances.get());
const [newBox1] = instancesDifference.keys();
expect($instances.get().get("body0")).toEqual(
createInstance("body0", "Body", [
{ type: "id", value: "box1" },
{ type: "id", value: newBox1 },
{ type: "id", value: "box2" },
])
);
expect(instancesDifference).toEqual(
toMap([createInstance(newBox1, "Box", [])])
);
});
});
describe("data sources", () => {
// body0
// box1
// box2
const instances: Instances = toMap([
createInstance("body0", "Body", [{ type: "id", value: "box1" }]),
createInstance("box1", "Box", [{ type: "id", value: "box2" }]),
createInstance("box2", "Box", []),
] satisfies Instance[]);
const dataSources: DataSources = toMap([
{
id: "box1$state",
scopeInstanceId: "box1",
type: "variable",
name: "state",
value: { type: "string", value: "initial" },
},
] satisfies DataSource[]);
const props: Props = toMap([
{
id: "box1$stateProp",
instanceId: "box1",
name: "state",
type: "expression",
value: "$ws$dataSource$box1$state",
},
{
id: "box2$stateProp",
instanceId: "box2",
name: "state",
type: "expression",
value: "$ws$dataSource$box1$state",
},
{
id: "box2$showProp",
instanceId: "box2",
name: "show",
type: "expression",
value: `$ws$dataSource$box1$state === 'initial'`,
},
{
id: "box2$onChangeProp",
instanceId: "box2",
type: "action",
name: "onChange",
value: [
{
type: "execute",
args: ["value"],
code: `$ws$dataSource$box1$state = value`,
},
],
},
] satisfies Prop[]);
test("are copy pasted when scoped to copied instances", async () => {
$instances.set(instances);
$dataSources.set(dataSources);
$props.set(props);
selectInstance(["box1", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["body0"]);
await instanceText.onPaste?.(clipboardData);
const instancesDifference = getMapDifference(instances, $instances.get());
const [newBox1, newBox2] = instancesDifference.keys();
const dataSourcesDifference = getMapDifference(
dataSources,
$dataSources.get()
);
const [newDataSource1] = dataSourcesDifference.keys();
expect(dataSourcesDifference).toEqual(
toMap([
{
...dataSources.get("box1$state"),
id: newDataSource1,
scopeInstanceId: newBox1,
},
])
);
const propsDifference = getMapDifference(props, $props.get());
const [newProp1, newProp2, newProp3, newProp4] = propsDifference.keys();
expect(propsDifference).toEqual(
toMap([
{
id: newProp1,
instanceId: newBox1,
name: "state",
type: "expression",
value: encodeDataSourceVariable(newDataSource1),
},
{
id: newProp2,
instanceId: newBox2,
name: "state",
type: "expression",
value: encodeDataSourceVariable(newDataSource1),
},
{
id: newProp3,
instanceId: newBox2,
name: "show",
type: "expression",
value: `${encodeDataSourceVariable(newDataSource1)} === 'initial'`,
},
{
id: newProp4,
instanceId: newBox2,
name: "onChange",
type: "action",
value: [
{
type: "execute",
args: ["value"],
code: `${encodeDataSourceVariable(newDataSource1)} = value`,
},
],
},
])
);
});
test("preserve data sources outside of scope when pasted within their scope", async () => {
$instances.set(instances);
$props.set(props);
$dataSources.set(dataSources);
selectInstance(["box2", "box1", "body0"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["box1", "body0"]);
await instanceText.onPaste?.(clipboardData);
const instancesDifference = getMapDifference(instances, $instances.get());
const [newBox2] = instancesDifference.keys();
const dataSourcesDifference = getMapDifference(
dataSources,
$dataSources.get()
);
expect(dataSourcesDifference).toEqual(new Map());
const propsDifference = getMapDifference(props, $props.get());
const [newProp1, newProp2, newProp3] = propsDifference.keys();
expect(propsDifference).toEqual(
toMap([
{
id: newProp1,
instanceId: newBox2,
name: "state",
type: "expression",
value: `$ws$dataSource$box1$state`,
},
{
id: newProp2,
instanceId: newBox2,
name: "show",
type: "expression",
value: `$ws$dataSource$box1$state === 'initial'`,
},
{
id: newProp3,
instanceId: newBox2,
type: "action",
name: "onChange",
value: [
{
args: ["value"],
code: "$ws$dataSource$box1$state = value",
type: "execute",
},
],
},
])
);
});
test("copy parameter prop with new data source", async () => {
const instances: Instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "list" }]),
createInstance("list", collectionComponent, []),
] satisfies Instance[]);
const dataSources: DataSources = toMap([
{
id: "itemDataSource",
scopeInstanceId: "list",
type: "parameter",
name: "item",
},
] satisfies DataSource[]);
const props: Props = toMap([
{
id: "dataProp",
instanceId: "list",
name: "data",
type: "json",
value: [],
},
{
id: "itemProp",
instanceId: "list",
name: "item",
type: "parameter",
value: "itemDataSource",
},
] satisfies Prop[]);
$instances.set(instances);
$props.set(props);
$dataSources.set(dataSources);
selectInstance(["list", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["body"]);
await instanceText.onPaste?.(clipboardData);
const instancesDifference = getMapDifference(instances, $instances.get());
const [collectionId] = instancesDifference.keys();
const dataSourcesDifference = getMapDifference(
dataSources,
$dataSources.get()
);
const [itemDataSourceId] = dataSourcesDifference.keys();
expect(dataSourcesDifference).toEqual(
new Map([
[
itemDataSourceId,
{
id: itemDataSourceId,
scopeInstanceId: collectionId,
type: "parameter",
name: "item",
},
],
])
);
const propsDifference = getMapDifference(props, $props.get());
const [dataPropId, itemPropId] = propsDifference.keys();
expect(propsDifference).toEqual(
toMap([
{
id: dataPropId,
instanceId: collectionId,
name: "data",
type: "json",
value: [],
},
{
id: itemPropId,
instanceId: collectionId,
name: "item",
type: "parameter",
value: itemDataSourceId,
},
])
);
});
});
test("when paste into copied instance insert after it", async () => {
$instances.set(
toMap([
createInstance("body", "Body", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
])
);
selectInstance(["box", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
await instanceText.onPaste?.(clipboardData);
expect($instances.get()).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "box" },
{ type: "id", value: expectString },
]),
createInstance("box", "Box", []),
createInstance(expectString, "Box", []),
])
);
});
test("prevent pasting portal into own descendants", async () => {
const instances = toMap([
createInstance("body", "Body", [{ type: "id", value: "portal" }]),
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
createInstance("box", "Box", []),
]);
$instances.set(instances);
selectInstance(["portal", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["fragment", "portal", "body"]);
await instanceText.onPaste?.(clipboardData);
expect($instances.get()).toEqual(instances);
});
test("prevent pasting portal into copy of it", async () => {
const instances = toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal1" },
{ type: "id", value: "portal2" },
]),
createInstance("portal1", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("portal2", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
]);
$instances.set(instances);
selectInstance(["box", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["portal", "body"]);
await instanceText.onPaste?.(clipboardData);
expect($instances.get()).toEqual(instances);
});
test("insert portal into its sibling", async () => {
$instances.set(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "sibling" },
]),
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
createInstance("sibling", "Box", []),
])
);
selectInstance(["portal", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["sibling", "body"]);
await instanceText.onPaste?.(clipboardData);
expect($instances.get()).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "sibling" },
]),
createInstance("portal", portalComponent, [
{ type: "id", value: "fragment" },
]),
createInstance("fragment", "Fragment", []),
createInstance("sibling", "Box", [{ type: "id", value: expectString }]),
createInstance(expectString, portalComponent, [
{ type: "id", value: "fragment" },
]),
])
);
});
test("insert into portal fragment when portal is a target", async () => {
$instances.set(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "box" },
]),
createInstance("portal", portalComponent, []),
createInstance("box", "Box", []),
])
);
selectInstance(["box", "body"]);
const clipboardData = instanceText.onCopy?.() ?? "";
selectInstance(["portal", "body"]);
// fragment not exists
const prevInstances = $instances.get();
await instanceText.onPaste?.(clipboardData);
const [boxId, fragmentId] = getMapDifference(
prevInstances,
$instances.get()
).keys();
expect($instances.get()).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "box" },
]),
createInstance("portal", portalComponent, [
{ type: "id", value: fragmentId },
]),
createInstance(fragmentId, "Fragment", [{ type: "id", value: boxId }]),
createInstance("box", "Box", []),
createInstance(boxId, "Box", []),
])
);
// fragment already exists
await instanceText.onPaste?.(clipboardData);
expect($instances.get()).toEqual(
toMap([
createInstance("body", "Body", [
{ type: "id", value: "portal" },
{ type: "id", value: "box" },
]),
createInstance("portal", portalComponent, [
{ type: "id", value: fragmentId },
]),
createInstance(fragmentId, "Fragment", [
{ type: "id", value: boxId },
{ type: "id", value: expectString },
]),
createInstance("box", "Box", []),
createInstance(boxId, "Box", []),
createInstance(expectString, "Box", []),
])
);
});