Files
Oleg Isonen ef94d85ccf feat: drag image assets onto canvas (#5912)
## Summary

- allow dragging a single image asset from the Assets panel onto the
canvas
- insert an Image component with its source bound to the dragged asset
in one undoable mutation
- preserve asset-folder drops and canceled drags
- build the Builder once in CI and reuse the compact build artifact
across all E2E shards

## E2E timing

Recent CI showed shard test runtimes of roughly 101–145 seconds, while
every shard also repeated a roughly two-minute Builder build. The four
shards are already reasonably balanced, so a fifth shard would not
address the primary bottleneck.

The shared artifact is about 30 MB uncompressed and roughly 5 MB
compressed because checked-out public assets are excluded. This should
return individual shard jobs to the 2–3 minute range.

## Validation

- 45 focused unit tests
- image asset to canvas E2E using the prebuilt-artifact path
- image asset to folder regression E2E
- Builder typecheck
- lint
- package-boundary validation
- workflow YAML validation
- shell syntax validation
2026-07-18 09:15:02 +02:00

1283 lines
37 KiB
TypeScript

import {
findClosestInsertable,
getComponentTemplateData,
getImageAssetFragment,
insertWebstudioComponentAt,
insertWebstudioFragmentAt,
} from "./insert";
import { insertWebstudioElementAt } from "./insert";
import { enableMapSet } from "immer";
import { describe, test, expect, beforeEach, vi } from "vitest";
import { toast } from "@webstudio-is/design-system";
import type { Project } from "@webstudio-is/project";
import { createDefaultPages } from "@webstudio-is/project-build";
import {
$,
ws,
css,
expression,
renderTemplate,
renderData,
token,
} from "@webstudio-is/template";
import * as defaultMetas from "@webstudio-is/sdk-components-react/metas";
import type { WebstudioData, WebstudioFragment } from "@webstudio-is/sdk";
import { coreMetas, elementComponent } from "@webstudio-is/sdk";
import {
$registeredComponentMetas,
$registeredTemplates,
} from "../nano-states";
import { $assets } from "~/shared/sync/data-stores";
import {
$breakpoints,
$dataSources,
$instances,
$pages,
$project,
$props,
$styleSourceSelections,
$styleSources,
$styles,
$resources,
} from "~/shared/sync/data-stores";
import { registerContainers } from "../sync/sync-stores";
import { getInstancePath } from "@webstudio-is/project-build/runtime";
import { selectPage } from "../nano-states";
import { selectInstance } from "../nano-states";
import { $selectedPageId } from "../nano-states/pages";
import { expectSlotsShareFragment } from "../slot-test-utils";
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 setDataStores = (data: Omit<WebstudioData, "pages">) => {
$instances.set(data.instances);
$breakpoints.set(data.breakpoints);
$styleSources.set(data.styleSources);
$styles.set(data.styles);
$styleSourceSelections.set(data.styleSourceSelections);
$dataSources.set(data.dataSources);
$props.set(data.props);
$assets.set(data.assets);
$resources.set(data.resources);
};
describe("insert webstudio element at", () => {
beforeEach(() => {
$pages.set(
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
);
selectPage("homePageId");
$styleSourceSelections.set(new Map());
$styleSources.set(new Map());
$breakpoints.set(new Map());
$styles.set(new Map());
$dataSources.set(new Map());
$resources.set(new Map());
$props.set(new Map());
$assets.set(new Map());
});
test("insert element with div tag into body", async () => {
$instances.set(renderData(<$.Body ws:id="bodyId"></$.Body>).instances);
await insertWebstudioElementAt({
parentSelector: ["bodyId"],
position: "end",
});
const [_bodyId, newInstanceId] = $instances.get().keys();
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id={newInstanceId} ws:tag="div" />
</$.Body>
).instances
);
});
test("insert element with li tag into ul", async () => {
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id="listId" ws:tag="ul"></ws.element>
</$.Body>
).instances
);
await insertWebstudioElementAt({
parentSelector: ["listId", "bodyId"],
position: "end",
});
const [_bodyId, _listId, newInstanceId] = $instances.get().keys();
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id="listId" ws:tag="ul">
<ws.element ws:id={newInstanceId} ws:tag="li" />
</ws.element>
</$.Body>
).instances
);
});
test("insert element into selected instance", async () => {
$pages.set(
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
);
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id="divId" ws:tag="div"></ws.element>
</$.Body>
).instances
);
selectPage("homePageId");
selectInstance(["divId", "bodyId"]);
await insertWebstudioElementAt();
const [_bodyId, _divId, newInstanceId] = $instances.get().keys();
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id="divId" ws:tag="div">
<ws.element ws:id={newInstanceId} ws:tag="div" />
</ws.element>
</$.Body>
).instances
);
});
test("insert element into selected legacy slot with direct children", async () => {
$pages.set(
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
);
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<$.Slot ws:id="slotId">
<$.Box ws:id="boxId"></$.Box>
</$.Slot>
</$.Body>
).instances
);
selectPage("homePageId");
selectInstance(["slotId", "bodyId"]);
await insertWebstudioElementAt();
const instances = $instances.get();
const fragmentId = instances.get("slotId")?.children[0]?.value;
const newInstanceId = Array.from(instances.keys()).find(
(id) =>
id !== "bodyId" &&
id !== "slotId" &&
id !== "boxId" &&
id !== fragmentId
);
expect(instances).toEqual(
renderData(
<$.Body ws:id="bodyId">
<$.Slot ws:id="slotId">
<$.Fragment ws:id={fragmentId}>
<$.Box ws:id="boxId"></$.Box>
<ws.element ws:id={newInstanceId} ws:tag="div" />
</$.Fragment>
</$.Slot>
</$.Body>
).instances
);
});
test("insert element into shared slot content", async () => {
$pages.set(
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
);
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<$.Slot ws:id="slot1">
<$.Fragment ws:id="fragment">
<$.Box ws:id="box"></$.Box>
</$.Fragment>
</$.Slot>
<$.Slot ws:id="slot2">
{/* same ids */}
<$.Fragment ws:id="fragment">
<$.Box ws:id="box"></$.Box>
</$.Fragment>
</$.Slot>
</$.Body>
).instances
);
selectPage("homePageId");
selectInstance(["slot1", "bodyId"]);
await insertWebstudioElementAt();
const newInstanceId = $instances.get().get("fragment")?.children[1]?.value;
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "box" },
{ type: "id", value: newInstanceId },
]);
expect($instances.get().get(newInstanceId ?? "")?.component).toBe(
elementComponent
);
});
test("insert element at start of shared slot content", async () => {
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<$.Slot ws:id="slot1">
<$.Fragment ws:id="fragment">
<$.Box ws:id="box"></$.Box>
</$.Fragment>
</$.Slot>
<$.Slot ws:id="slot2">
{/* same ids */}
<$.Fragment ws:id="fragment">
<$.Box ws:id="box"></$.Box>
</$.Fragment>
</$.Slot>
</$.Body>
).instances
);
await insertWebstudioElementAt({
parentSelector: ["slot1", "bodyId"],
position: 0,
});
const newInstanceId = $instances.get().get("fragment")?.children[0]?.value;
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: newInstanceId },
{ type: "id", value: "box" },
]);
expect($instances.get().get(newInstanceId ?? "")?.component).toBe(
elementComponent
);
});
test("insert element into nested shared slot content", async () => {
$pages.set(
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
);
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<$.Slot ws:id="slot1">
<$.Fragment ws:id="fragment">
<ws.element ws:id="div" ws:tag="div"></ws.element>
</$.Fragment>
</$.Slot>
<$.Slot ws:id="slot2">
{/* same ids */}
<$.Fragment ws:id="fragment">
<ws.element ws:id="div" ws:tag="div"></ws.element>
</$.Fragment>
</$.Slot>
</$.Body>
).instances
);
selectPage("homePageId");
selectInstance(["div", "fragment", "slot1", "bodyId"]);
await insertWebstudioElementAt();
const newInstanceId = $instances.get().get("div")?.children[0]?.value;
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "div" },
]);
expect($instances.get().get("div")?.children).toEqual([
{ type: "id", value: newInstanceId },
]);
expect($instances.get().get(newInstanceId ?? "")?.component).toBe(
elementComponent
);
});
test("insert element into closest non-textual container", async () => {
$pages.set(
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
);
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id="divId" ws:tag="div">
text
</ws.element>
<ws.element ws:id="spanId" ws:tag="span"></ws.element>
</$.Body>
).instances
);
selectPage("homePageId");
selectInstance(["divId", "bodyId"]);
await insertWebstudioElementAt();
const [_bodyId, _divId, _spanId, newInstanceId] = $instances.get().keys();
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id="divId" ws:tag="div">
text
</ws.element>
<ws.element ws:id={newInstanceId} ws:tag="div" />
<ws.element ws:id="spanId" ws:tag="span"></ws.element>
</$.Body>
).instances
);
});
test("insert element into closest non-empty container", async () => {
$pages.set(
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
);
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id="imgId" ws:tag="img"></ws.element>
<ws.element ws:id="spanId" ws:tag="span"></ws.element>
</$.Body>
).instances
);
selectPage("homePageId");
selectInstance(["imgId", "bodyId"]);
await insertWebstudioElementAt();
const [_bodyId, _imgId, _spanId, newInstanceId] = $instances.get().keys();
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id="imgId" ws:tag="img"></ws.element>
<ws.element ws:id={newInstanceId} ws:tag="div" />
<ws.element ws:id="spanId" ws:tag="span"></ws.element>
</$.Body>
).instances
);
});
test("reports unresolved explicit element insert target", async () => {
const toastError = vi.spyOn(toast, "error").mockImplementation(() => "");
$instances.set(renderData(<$.Body ws:id="bodyId"></$.Body>).instances);
expect(
await insertWebstudioElementAt({
parentSelector: ["missingId"],
position: "end",
})
).toBe(false);
expect(toastError).toHaveBeenCalledWith(
"Cannot insert: the target no longer exists."
);
expect($instances.get()).toEqual(
renderData(<$.Body ws:id="bodyId"></$.Body>).instances
);
toastError.mockRestore();
});
});
describe("insert webstudio fragment at", () => {
beforeEach(() => {
$project.set({ id: "current_project" } as Project);
$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", async () => {
$instances.set(renderData(<$.Body ws:id="bodyId"></$.Body>).instances);
await insertWebstudioFragmentAt(
renderTemplate(
<>
<ws.element ws:id="headingId" ws:tag="h1"></ws.element>
<ws.element ws:id="paragraphId" ws:tag="p"></ws.element>
</>
),
{
parentSelector: ["bodyId"],
position: "end",
}
);
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id={expect.any(String)} ws:tag="h1"></ws.element>
<ws.element ws:id={expect.any(String)} ws:tag="p"></ws.element>
</$.Body>
).instances
);
});
test("returns false for empty fragments", async () => {
expect(await insertWebstudioFragmentAt(createFragment({}))).toBe(false);
});
test("returns false for tokens-only fragments without a project", async () => {
$project.set(undefined);
expect(
await insertWebstudioFragmentAt(
createFragment({
styleSources: [{ type: "token", id: "token", name: "Token" }],
})
)
).toBe(false);
});
test("inserts tokens-only fragments without an insert target", async () => {
expect(
await insertWebstudioFragmentAt(
createFragment({
styleSources: [{ type: "token", id: "token", name: "Token" }],
styles: [
{
styleSourceId: "token",
breakpointId: "base",
property: "color",
value: { type: "keyword", value: "red" },
},
],
breakpoints: [{ id: "base", label: "" }],
})
)
).toBe(true);
expect(Array.from($styleSources.get().values())).toEqual([
{ type: "token", id: expect.any(String), name: "Token" },
]);
expect(Array.from($styles.get().values())).toEqual([
expect.objectContaining({
property: "color",
value: { type: "keyword", value: "red" },
}),
]);
});
test("insert fragment after insertable", async () => {
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<$.Box ws:id="boxId"></$.Box>
</$.Body>
).instances
);
await insertWebstudioFragmentAt(
renderTemplate(<ws.element ws:id="headingId" ws:tag="h1"></ws.element>),
{
parentSelector: ["boxId", "bodyId"],
position: "after",
}
);
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<$.Box ws:id="boxId"></$.Box>
<ws.element ws:id={expect.any(String)} ws:tag="h1"></ws.element>
</$.Body>
).instances
);
});
test("insert fragment inside of body when configured to place after insertable", async () => {
$instances.set(renderData(<$.Body ws:id="bodyId"></$.Body>).instances);
await insertWebstudioFragmentAt(
renderTemplate(<ws.element ws:id="headingId" ws:tag="h1"></ws.element>),
{
parentSelector: ["bodyId"],
position: "after",
}
);
expect($instances.get()).toEqual(
renderData(
<$.Body ws:id="bodyId">
<ws.element ws:id={expect.any(String)} ws:tag="h1"></ws.element>
</$.Body>
).instances
);
});
test("reports unresolved explicit insert target", async () => {
const toastError = vi.spyOn(toast, "error").mockImplementation(() => "");
$instances.set(renderData(<$.Body ws:id="bodyId"></$.Body>).instances);
expect(
await insertWebstudioFragmentAt(
renderTemplate(<ws.element ws:id="headingId" ws:tag="h1"></ws.element>),
{
parentSelector: ["missingId"],
position: "end",
}
)
).toBe(false);
expect(toastError).toHaveBeenCalledWith(
"Cannot insert: the target no longer exists."
);
expect($instances.get()).toEqual(
renderData(<$.Body ws:id="bodyId"></$.Body>).instances
);
toastError.mockRestore();
});
test("insert fragment into shared slot content", async () => {
$project.set({ id: "current_project" } as Project);
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<$.Slot ws:id="slot1">
<$.Fragment ws:id="fragment">
<$.Box ws:id="box"></$.Box>
</$.Fragment>
</$.Slot>
<$.Slot ws:id="slot2">
{/* same ids */}
<$.Fragment ws:id="fragment">
<$.Box ws:id="box"></$.Box>
</$.Fragment>
</$.Slot>
</$.Body>
).instances
);
await insertWebstudioFragmentAt(
renderTemplate(<ws.element ws:id="heading" ws:tag="h1"></ws.element>),
{
parentSelector: ["slot1", "bodyId"],
position: "end",
}
);
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "box" },
{ type: "id", value: expect.any(String) },
]);
});
test("insert fragment into legacy shared slot content normalizes all occurrences", async () => {
$project.set({ id: "current_project" } as Project);
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<$.Slot ws:id="slot1">
<$.Box ws:id="box"></$.Box>
</$.Slot>
<$.Slot ws:id="slot2">
{/* same ids */}
<$.Box ws:id="box"></$.Box>
</$.Slot>
</$.Body>
).instances
);
await insertWebstudioFragmentAt(
renderTemplate(<ws.element ws:id="heading" ws:tag="h1"></ws.element>),
{
parentSelector: ["slot1", "bodyId"],
position: "end",
}
);
const fragmentId = expectSlotsShareFragment($instances.get(), [
"slot1",
"slot2",
]);
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: fragmentId },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: fragmentId },
]);
expect($instances.get().get(fragmentId ?? "")?.children).toEqual([
{ type: "id", value: "box" },
{ type: "id", value: expect.any(String) },
]);
});
test("insert fragment after shared slot child", async () => {
$project.set({ id: "current_project" } as Project);
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<$.Slot ws:id="slot1">
<$.Fragment ws:id="fragment">
<$.Box ws:id="box"></$.Box>
</$.Fragment>
</$.Slot>
<$.Slot ws:id="slot2">
{/* same ids */}
<$.Fragment ws:id="fragment">
<$.Box ws:id="box"></$.Box>
</$.Fragment>
</$.Slot>
</$.Body>
).instances
);
await insertWebstudioFragmentAt(
renderTemplate(<ws.element ws:id="heading" ws:tag="h1"></ws.element>),
{
parentSelector: ["box", "fragment", "slot1", "bodyId"],
position: "after",
}
);
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "box" },
{ type: "id", value: expect.any(String) },
]);
});
test("insert fragment after nested shared slot child", async () => {
$project.set({ id: "current_project" } as Project);
$instances.set(
renderData(
<$.Body ws:id="bodyId">
<$.Slot ws:id="slot1">
<$.Fragment ws:id="fragment">
<ws.element ws:id="div" ws:tag="div">
<$.Box ws:id="box"></$.Box>
</ws.element>
</$.Fragment>
</$.Slot>
<$.Slot ws:id="slot2">
{/* same ids */}
<$.Fragment ws:id="fragment">
<ws.element ws:id="div" ws:tag="div">
<$.Box ws:id="box"></$.Box>
</ws.element>
</$.Fragment>
</$.Slot>
</$.Body>
).instances
);
await insertWebstudioFragmentAt(
renderTemplate(<ws.element ws:id="heading" ws:tag="h1"></ws.element>),
{
parentSelector: ["box", "div", "fragment", "slot1", "bodyId"],
position: "after",
}
);
expect($instances.get().get("slot1")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("slot2")?.children).toEqual([
{ type: "id", value: "fragment" },
]);
expect($instances.get().get("fragment")?.children).toEqual([
{ type: "id", value: "div" },
]);
expect($instances.get().get("div")?.children).toEqual([
{ type: "id", value: "box" },
{ type: "id", value: expect.any(String) },
]);
});
});
describe("insert webstudio component at", () => {
beforeEach(() => {
$project.set({ id: "current_project" } as Project);
$pages.set(
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
);
selectPage("homePageId");
$instances.set(renderData(<$.Body ws:id="bodyId"></$.Body>).instances);
$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("inserts component through runtime template application", async () => {
expect(
await insertWebstudioComponentAt("Form", {
parentSelector: ["bodyId"],
position: "end",
})
).toBe(true);
const body = $instances.get().get("bodyId");
const formId =
body?.children[0]?.type === "id" ? body.children[0].value : "";
const form = $instances.get().get(formId);
expect(form).toEqual({
type: "instance",
id: formId,
component: "Form",
children: expect.any(Array),
});
});
});
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: "",
})
);
$selectedPageId.set("homePageId");
selectInstance(["collectionId[1]", "collectionId", "bodyId"]);
$registeredComponentMetas.set(defaultMetasMap);
});
test("returns undefined without a selected page", () => {
$selectedPageId.set(undefined);
expect(findClosestInsertable(newBoxFragment)).toBeUndefined();
});
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">
<$.Image ws:id="imageId"></$.Image>
</$.Paragraph>
</$.Body>
);
$instances.set(instances);
selectInstance(["imageId", "paragraphId", "bodyId"]);
expect(
findClosestInsertable(renderTemplate(<$.Box ws:tag="span"></$.Box>))
).toEqual({
parentSelector: ["paragraphId", "bodyId"],
position: 1,
});
});
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("prevents inserting list item in body when validation fails", () => {
const { instances } = renderData(<$.Body ws:id="bodyId"></$.Body>);
$instances.set(instances);
selectInstance(["bodyId"]);
const newListItemFragment = renderTemplate(
<$.ListItem ws:id="newListItemId"></$.ListItem>
);
expect(findClosestInsertable(newListItemFragment)).toBeUndefined();
});
});
describe("getComponentTemplateData", () => {
test("returns registered template data when available", () => {
const template = createFragment({
children: [{ type: "id", value: "template-root" }],
instances: [
{
type: "instance",
id: "template-root",
component: "Box",
children: [],
},
],
});
$registeredTemplates.set(
new Map([
[
"Template",
{
category: "general",
order: 0,
template,
},
],
])
);
expect(getComponentTemplateData("Template")).toBe(template);
});
test("builds fallback fragment for component names", () => {
$registeredTemplates.set(new Map());
const fragment = getComponentTemplateData("Box");
expect(fragment.children).toEqual([
{ type: "id", value: expect.any(String) },
]);
expect(fragment.instances).toEqual([
{
type: "instance",
id: fragment.children[0].value,
component: "Box",
children: [],
},
]);
});
});
test("binds an Image template source to an asset without mutating the template", () => {
const template = createFragment({
children: [{ type: "id", value: "image" }],
instances: [
{ type: "instance", id: "image", component: "Image", children: [] },
],
props: [
{
id: "source",
instanceId: "image",
name: "src",
type: "string",
value: "placeholder.png",
},
{
id: "alt",
instanceId: "image",
name: "alt",
type: "string",
value: "Placeholder",
},
],
});
$registeredTemplates.set(
new Map([["Image", { category: "media", order: 0, template }]])
);
const fragment = getImageAssetFragment("asset-id");
expect(fragment.props).toEqual([
{
id: "source",
instanceId: "image",
name: "src",
type: "asset",
value: "asset-id",
},
template.props[1],
]);
expect(template.props[0]).toEqual(
expect.objectContaining({ type: "string", value: "placeholder.png" })
);
});
test("get undefined instead of instance path when no instances found", () => {
expect(getInstancePath(["boxId"], new Map())).toEqual(undefined);
});
describe("insertWebstudioFragmentAt with conflictResolution", () => {
beforeEach(() => {
$project.set({ id: "project-id" } as Project);
});
test("uses conflictResolution='theirs' by default (creates new token with suffix)", async () => {
// Existing project with a "primary" token (used by existing-box)
const data = renderData(
<$.Body ws:id="body">
<$.Box
ws:id="existing-box"
ws:tokens={[
token(
"primary",
css`
color: blue;
`
),
]}
></$.Box>
</$.Body>
);
// Create fragment with token that has same name but different styles
const fragment = renderTemplate(
<ws.element
ws:id="box"
ws:tag="div"
ws:tokens={[
token(
"primary",
css`
color: red;
`
),
]}
></ws.element>
);
setDataStores(data);
const pages = createDefaultPages({ rootInstanceId: "body" });
$pages.set(pages);
$selectedPageId.set(pages.homePageId);
selectInstance(["body"]);
// Insert without explicit conflictResolution (defaults to "theirs")
await insertWebstudioFragmentAt(fragment, {
parentSelector: ["body"],
position: "end",
});
// The existing token should still have blue color (unchanged)
const styles = Array.from($styles.get().values());
const styleSources = Array.from($styleSources.get().values());
const existingToken = styleSources.find(
(s) => s.type === "token" && s.name === "primary"
);
const existingTokenStyle = styles.find(
(s) =>
s.styleSourceId === existingToken?.id &&
s.property === "color" &&
s.breakpointId === "base"
);
expect(existingTokenStyle?.value).toEqual({
type: "keyword",
value: "blue",
});
// A new token "primary-1" should be created with red color
const newToken = styleSources.find(
(s) => s.type === "token" && s.name === "primary-1"
);
expect(newToken).toBeDefined();
const newTokenStyle = styles.find(
(s) =>
s.styleSourceId === newToken?.id &&
s.property === "color" &&
s.breakpointId === "base"
);
expect(newTokenStyle?.value).toEqual({ type: "keyword", value: "red" });
});
test("uses conflictResolution='ours' to keep existing token styles", async () => {
// Existing project with a "primary" token (used by existing-box)
const data = renderData(
<$.Body ws:id="body">
<$.Box
ws:id="existing-box"
ws:tokens={[
token(
"primary",
css`
color: blue;
`
),
]}
></$.Box>
</$.Body>
);
const fragment = renderTemplate(
<ws.element
ws:id="box"
ws:tag="div"
ws:tokens={[
token(
"primary",
css`
color: red;
`
),
]}
></ws.element>
);
setDataStores(data);
const pages = createDefaultPages({ rootInstanceId: "body" });
$pages.set(pages);
$selectedPageId.set(pages.homePageId);
selectInstance(["body"]);
// Insert with conflictResolution="ours" to keep existing styles
await insertWebstudioFragmentAt(
fragment,
{
parentSelector: ["body"],
position: "end",
},
"ours"
);
// The existing token should still have blue color (kept original)
const styles = Array.from($styles.get().values());
const styleSources = Array.from($styleSources.get().values());
const existingToken = styleSources.find(
(s) => s.type === "token" && s.name === "primary"
);
const primaryTokenStyle = styles.find(
(s) =>
s.styleSourceId === existingToken?.id &&
s.property === "color" &&
s.breakpointId === "base"
);
expect(primaryTokenStyle?.value).toEqual({
type: "keyword",
value: "blue",
});
// No new token should be created
const newToken = styleSources.find(
(s) => s.type === "token" && s.name === "primary-1"
);
expect(newToken).toBeUndefined();
});
test("uses conflictResolution='merge' to merge styles (theirs overrides)", async () => {
// Existing project with a "primary" token that has color and fontSize
const data = renderData(
<$.Body ws:id="body">
<$.Box
ws:id="existing-box"
ws:tokens={[
token(
"primary",
css`
color: blue;
font-size: 16px;
`
),
]}
></$.Box>
</$.Body>
);
// Fragment with same "primary" token but different color and new property
const fragment = renderTemplate(
<ws.element
ws:id="box"
ws:tag="div"
ws:tokens={[
token(
"primary",
css`
color: red;
font-weight: bold;
`
),
]}
></ws.element>
);
setDataStores(data);
const pages = createDefaultPages({ rootInstanceId: "body" });
$pages.set(pages);
$selectedPageId.set(pages.homePageId);
selectInstance(["body"]);
// Insert with conflictResolution="merge"
await insertWebstudioFragmentAt(
fragment,
{
parentSelector: ["body"],
position: "end",
},
"merge"
);
// The existing token should now have red color (overridden by fragment)
const styles = Array.from($styles.get().values());
const styleSources = Array.from($styleSources.get().values());
const existingToken = styleSources.find(
(s) => s.type === "token" && s.name === "primary"
);
const colorStyle = styles.find(
(s) =>
s.styleSourceId === existingToken?.id &&
s.property === "color" &&
s.breakpointId === "base"
);
expect(colorStyle?.value).toEqual({ type: "keyword", value: "red" });
// fontSize should still be there (not in fragment, so kept)
const fontSizeStyle = styles.find(
(s) =>
s.styleSourceId === existingToken?.id &&
s.property === "fontSize" &&
s.breakpointId === "base"
);
expect(fontSizeStyle?.value).toEqual({
type: "unit",
value: 16,
unit: "px",
});
// fontWeight should be added from fragment
const fontWeightStyle = styles.find(
(s) =>
s.styleSourceId === existingToken?.id &&
s.property === "fontWeight" &&
s.breakpointId === "base"
);
expect(fontWeightStyle?.value).toEqual({ type: "keyword", value: "bold" });
// No new token should be created
const newToken = styleSources.find(
(s) => s.type === "token" && s.name === "primary-1"
);
expect(newToken).toBeUndefined();
});
});