mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-27 15:16:53 +09:00
Closes #6605 New Forms show editable feedback while Resource actions run. The `formProgress` variable starts with `status: "idle"`, `loaded: 0`, `total: 0`, and `files: []`. It becomes pending during submission, then returns to idle. The existing `formState` still reports only initial, success, or error. The Progress container says “Submitting…” and adds measured whole-request bytes when the total is known. Files include ID, field name, name, size, and type. The browser cannot measure reliable per-file transfer bytes in the single multipart request. The binding editor suggests one example file to reveal those fields; the example never enters the actual Form value. Saved Forms retain their existing bindings and submission message. ## Checklist - [x] Keep the existing Form outcome variable and add upload activity, byte counts, and file details. - [x] Show zero byte defaults on insertion, new submissions, cancellation, and Preview reset without fake runtime files. - [x] Suggest file fields in Form and Resource bindings while evaluation, reset, rendering, and stored values use real data. - [x] Preserve Builder Preview headers, authentication, response checks, and cancellation. - [x] Keep existing success/error feedback and saved-Form fallback. - [x] Reset external progress when an active published Form unmounts. - [x] Update Form, Variables, and MCP/CLI guides; clarify the older screenshot and final-only simulation. Generated MCP docs check passes. - [x] Audit Form and Variables docs against final progress behavior; clarify the older Variables screenshot. - [x] Cover lifecycle, transport, repeated submission, cancellation, unmount, Preview, template, MCP binding/style, and editor completion with focused tests. - [x] Run relevant typechecks, lint, formatting, fixture generation, and independent review. - [x] Required CI checks pass on the latest commit. ## Verification Local focused Form browser tests (25), Form template tests (12), Builder Preview and Form context tests (14), and mounted editor example/reset tests (6) pass. Builder, SDK React, SDK registry, and project-build typechecks pass, as do lint, formatting, and fixture generation. The fixture sync regenerated two unrelated linked-project site paths; those outputs were restored before commit. Independent review found no remaining issues. All latest-commit checks passed; E2E shard 9 passed on a targeted rerun after its two Form tests also passed locally with 11 related tests. This follow-up adds 162 net non-test lines, more than the initial estimate. The extra code identifies the actual callback-bound variable by ID, even when renamed, and gives autocomplete a separate example scope so it cannot affect actual values. One provider covers Form fields and Resource editors. No schema or package was added. The existing Variables screenshot predates `formProgress`; its explanatory text is current. The React Router fixture's built browser client grew by 4 KiB on Linux CI (376 → 380 KiB), and its checked limit matches that measured output. Multipart transfer remains one whole request. Browser submission supplies live progress. MCP can discover `formProgress`, bind the Progress container, and style it; server simulation reports final outcomes and does not measure browser uploads.
1797 lines
53 KiB
TypeScript
1797 lines
53 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 { lintExpression } from "@webstudio-is/expression";
|
|
import type { Project } from "@webstudio-is/project";
|
|
import { createDefaultPages } from "@webstudio-is/project-build";
|
|
import {
|
|
createTemplateComponentFixture,
|
|
ws,
|
|
css,
|
|
expression,
|
|
renderTemplate,
|
|
renderData,
|
|
token,
|
|
type TemplateMeta,
|
|
} from "@webstudio-is/template";
|
|
import * as defaultMetas from "@webstudio-is/sdk-components-react/metas";
|
|
import { coreTemplates } from "@webstudio-is/sdk-components-registry/core-templates";
|
|
import { componentIds } from "@webstudio-is/sdk-components-registry/components";
|
|
import type { Prop, WebstudioData, WebstudioFragment } from "@webstudio-is/sdk";
|
|
import {
|
|
blockBodyComponent,
|
|
blockComponent,
|
|
coreMetas,
|
|
elementComponent,
|
|
encodeDataSourceVariable,
|
|
executeExpression,
|
|
isFormSubmission,
|
|
} 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 {
|
|
findAvailableVariables,
|
|
getInstancePath,
|
|
} from "@webstudio-is/project-build/runtime";
|
|
import { getInstanceKey, selectPage } from "../nano-states";
|
|
import { selectInstance } from "../nano-states";
|
|
import { $propValuesByInstanceSelector } from "../nano-states/props";
|
|
import { $dataSourceVariables } from "../nano-states/variables";
|
|
import { $selectedPageId } from "../nano-states/pages";
|
|
import { expectSlotsShareFragment } from "../slot-test-utils";
|
|
import { $selectedInstancePropsMetas } from "~/builder/features/settings-panel/shared";
|
|
import { $instanceTags } from "~/builder/features/style-panel/shared/model";
|
|
|
|
const Body = createTemplateComponentFixture("Body");
|
|
const Bold = createTemplateComponentFixture("Bold");
|
|
const Box = createTemplateComponentFixture("Box");
|
|
const Fragment = createTemplateComponentFixture("Fragment");
|
|
const Image = createTemplateComponentFixture("Image");
|
|
const ListItem = createTemplateComponentFixture("ListItem");
|
|
const Paragraph = createTemplateComponentFixture("Paragraph");
|
|
const Slot = createTemplateComponentFixture("Slot");
|
|
|
|
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("allows legacy fragment warnings through the internal paste context", async () => {
|
|
$pages.set(
|
|
createDefaultPages({ homePageId: "homePageId", rootInstanceId: "bodyId" })
|
|
);
|
|
selectPage("homePageId");
|
|
$instances.set(renderData(<Body ws:id="bodyId"></Body>).instances);
|
|
const onContentModelWarnings = vi.fn();
|
|
|
|
const inserted = insertWebstudioFragmentAt(
|
|
renderTemplate(
|
|
<ws.element ws:id="button" ws:tag="button">
|
|
<ws.element ws:id="heading" ws:tag="h3" />
|
|
</ws.element>
|
|
),
|
|
{ parentSelector: ["bodyId"], position: "end" },
|
|
undefined,
|
|
{ allowContentModelWarnings: true, onContentModelWarnings }
|
|
);
|
|
|
|
expect(inserted).toBe(true);
|
|
expect($instances.get().get("bodyId")?.children).toHaveLength(1);
|
|
expect(onContentModelWarnings).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
message: "Placing <h3> element inside a <button> violates HTML spec.",
|
|
}),
|
|
]);
|
|
});
|
|
|
|
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 a File Input and keeps its upload controls after reopening saved props", async () => {
|
|
const previousTemplates = $registeredTemplates.get();
|
|
$registeredTemplates.set(
|
|
new Map([
|
|
[
|
|
"file_input",
|
|
{
|
|
category: "forms",
|
|
template: renderTemplate(
|
|
(coreTemplates as Record<string, TemplateMeta>).file_input
|
|
.template,
|
|
undefined,
|
|
[],
|
|
{
|
|
componentIds,
|
|
componentMetas: defaultMetasMap,
|
|
}
|
|
),
|
|
},
|
|
],
|
|
])
|
|
);
|
|
try {
|
|
expect(
|
|
await insertWebstudioComponentAt("file_input", {
|
|
parentSelector: ["bodyId"],
|
|
position: "end",
|
|
})
|
|
).toBe(true);
|
|
const child = $instances.get().get("bodyId")?.children[0];
|
|
const inputId = child?.type === "id" ? child.value : "";
|
|
expect($instances.get().get(inputId)).toMatchObject({
|
|
component: elementComponent,
|
|
tag: "input",
|
|
label: "File Input",
|
|
});
|
|
const configuredProps = new Map($props.get());
|
|
const nameProp = Array.from(configuredProps.values()).find(
|
|
({ instanceId, name }) => instanceId === inputId && name === "name"
|
|
);
|
|
if (nameProp?.type !== "string") {
|
|
throw new Error("Expected a named file input");
|
|
}
|
|
configuredProps.set(nameProp.id, { ...nameProp, value: "attachments" });
|
|
for (const prop of [
|
|
{
|
|
id: "upload-accept",
|
|
instanceId: inputId,
|
|
name: "accept",
|
|
type: "string" as const,
|
|
value: "image/*,.pdf",
|
|
},
|
|
...(["required", "multiple"] as const).map((name) => ({
|
|
id: `upload-${name}`,
|
|
instanceId: inputId,
|
|
name,
|
|
type: "boolean" as const,
|
|
value: true,
|
|
})),
|
|
]) {
|
|
configuredProps.set(prop.id, prop);
|
|
}
|
|
$props.set(configuredProps);
|
|
const savedProps = new Map(
|
|
JSON.parse(JSON.stringify(Array.from($props.get()))) as Array<
|
|
[string, Prop]
|
|
>
|
|
);
|
|
$props.set(savedProps);
|
|
expect(
|
|
Array.from(savedProps.values())
|
|
.filter(({ instanceId }) => instanceId === inputId)
|
|
.map(({ name, value }) => [name, value])
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
["type", "file"],
|
|
["name", "attachments"],
|
|
["accept", "image/*,.pdf"],
|
|
["required", true],
|
|
["multiple", true],
|
|
])
|
|
);
|
|
selectInstance([inputId, "bodyId"]);
|
|
expect($instanceTags.get().get(inputId)).toBe("input");
|
|
expect(Array.from($selectedInstancePropsMetas.get().keys())).toEqual(
|
|
expect.arrayContaining([
|
|
"type",
|
|
"name",
|
|
"required",
|
|
"accept",
|
|
"multiple",
|
|
])
|
|
);
|
|
} finally {
|
|
$registeredTemplates.set(previousTemplates);
|
|
}
|
|
});
|
|
|
|
test("inserts the Forms tile as a contact form with two Email actions", async () => {
|
|
const previousTemplates = $registeredTemplates.get();
|
|
$registeredTemplates.set(
|
|
new Map([
|
|
[
|
|
"form",
|
|
{
|
|
category: "forms",
|
|
template: renderTemplate(
|
|
coreTemplates.form.template,
|
|
undefined,
|
|
[],
|
|
{
|
|
componentIds,
|
|
componentMetas: defaultMetasMap,
|
|
}
|
|
),
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
try {
|
|
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 : "";
|
|
expect($instances.get().get(formId)?.component).toBe("ResourceForm");
|
|
const status = Array.from($instances.get().values()).find(
|
|
(instance) => instance.label === "Progress"
|
|
);
|
|
expect(status).toBeDefined();
|
|
expect(
|
|
Array.from($props.get().values()).find(
|
|
(prop) =>
|
|
prop.instanceId === status?.id && prop.name === "data-ws-show"
|
|
)
|
|
).toMatchObject({ type: "expression" });
|
|
const summaryId = status?.children.find(
|
|
(child) => child.type === "id"
|
|
)?.value;
|
|
const summary = $instances.get().get(summaryId ?? "");
|
|
const summaryText = summary?.children.find(
|
|
(child) => child.type === "expression"
|
|
);
|
|
if (summaryText?.type !== "expression") {
|
|
throw new Error("Expected a bound Form upload summary");
|
|
}
|
|
expect(
|
|
lintExpression({
|
|
expression: summaryText.value,
|
|
availableVariables: new Set(
|
|
Array.from($dataSources.get().keys(), encodeDataSourceVariable)
|
|
),
|
|
})
|
|
).toEqual([]);
|
|
const formProps = Array.from($props.get().values()).filter(
|
|
({ instanceId }) => instanceId === formId
|
|
);
|
|
const action = formProps.find(({ name }) => name === "action");
|
|
expect(action?.type).toBe("json");
|
|
if (action?.type !== "json") {
|
|
throw new Error("Expected configured Form actions");
|
|
}
|
|
if (!isFormSubmission(action.value)) {
|
|
throw new Error("Expected valid Form actions");
|
|
}
|
|
expect(action.value).toEqual([
|
|
{ dataSourceId: expect.any(String) },
|
|
{ dataSourceId: expect.any(String) },
|
|
]);
|
|
expect(action.value).toHaveLength(2);
|
|
expect(
|
|
action.value.map(
|
|
({ dataSourceId }: { dataSourceId: string }) =>
|
|
$dataSources.get().get(dataSourceId)?.scopeInstanceId
|
|
)
|
|
).toEqual([formId, formId]);
|
|
const insertedEmailSubjects = action.value.map(({ dataSourceId }) => {
|
|
const dataSource = $dataSources.get().get(dataSourceId);
|
|
if (dataSource?.type !== "resource") {
|
|
throw new Error("Expected an Email Resource on the new Form");
|
|
}
|
|
return $resources.get().get(dataSource.resourceId)?.email?.subject;
|
|
});
|
|
expect(insertedEmailSubjects).toHaveLength(2);
|
|
for (const subject of insertedEmailSubjects) {
|
|
expect(subject).toContain(".subject");
|
|
expect(subject).not.toContain("?.");
|
|
}
|
|
const ownerDataSource = $dataSources
|
|
.get()
|
|
.get(action.value[0].dataSourceId);
|
|
if (ownerDataSource?.type !== "resource") {
|
|
throw new Error("Expected owner Email Resource");
|
|
}
|
|
const ownerBody = $resources.get().get(ownerDataSource.resourceId)
|
|
?.email?.body;
|
|
const formDataProp = formProps.find(({ name }) => name === "formData");
|
|
const browserInfoProp = formProps.find(
|
|
({ name }) => name === "browserInfo"
|
|
);
|
|
if (
|
|
ownerBody === undefined ||
|
|
formDataProp?.type !== "parameter" ||
|
|
browserInfoProp?.type !== "parameter"
|
|
) {
|
|
throw new Error("Expected owner Email body and Form parameters");
|
|
}
|
|
expect(ownerBody).not.toContain("Submitted fields:");
|
|
expect(
|
|
lintExpression({
|
|
expression: ownerBody,
|
|
availableVariables: new Set([
|
|
encodeDataSourceVariable(formDataProp.value),
|
|
encodeDataSourceVariable(browserInfoProp.value),
|
|
]),
|
|
})
|
|
).toEqual([]);
|
|
const evaluateOwnerBody = (pageUrl: string) =>
|
|
executeExpression(
|
|
ownerBody
|
|
.replaceAll(
|
|
encodeDataSourceVariable(browserInfoProp.value),
|
|
JSON.stringify({ pageUrl })
|
|
)
|
|
.replaceAll(
|
|
encodeDataSourceVariable(formDataProp.value),
|
|
JSON.stringify("Name: Ada")
|
|
)
|
|
);
|
|
expect(evaluateOwnerBody("https://example.com/contact")).toBe(
|
|
"Page: https://example.com/contact\n\nName: Ada"
|
|
);
|
|
expect(evaluateOwnerBody("")).toBe("Name: Ada");
|
|
expect(formProps.some(({ name }) => name === "onStateChange")).toBe(true);
|
|
expect(formProps.some(({ name }) => name === "onProgressChange")).toBe(
|
|
true
|
|
);
|
|
const resultProp = formProps.find(
|
|
({ name }) => name === "onResultChange"
|
|
);
|
|
expect(resultProp?.type).toBe("action");
|
|
if (resultProp?.type !== "action") {
|
|
throw new Error("Expected a Form result action");
|
|
}
|
|
const [resultAction] = resultProp.value;
|
|
expect(resultAction?.type).toBe("execute");
|
|
if (resultAction?.type !== "execute") {
|
|
throw new Error("Expected an executable Form result action");
|
|
}
|
|
expect(
|
|
lintExpression({
|
|
expression: resultAction.code,
|
|
allowAssignment: true,
|
|
availableVariables: new Set([
|
|
"result",
|
|
...Array.from($dataSources.get().keys()).map(
|
|
encodeDataSourceVariable
|
|
),
|
|
]),
|
|
})
|
|
).toEqual([]);
|
|
for (const name of ["formProgress", "results", "errors"]) {
|
|
expect(
|
|
Array.from($dataSources.get().values()).some(
|
|
(dataSource) =>
|
|
dataSource.type === "variable" && dataSource.name === name
|
|
)
|
|
).toBe(true);
|
|
}
|
|
const result = {
|
|
success: false,
|
|
status: 502,
|
|
results: [{ resourceId: "first", status: 200, body: "ok" }],
|
|
errors: [
|
|
{
|
|
resourceId: "second",
|
|
status: 502,
|
|
body: "failed",
|
|
message: "failed",
|
|
},
|
|
],
|
|
};
|
|
await vi.waitFor(() => {
|
|
expect(
|
|
$propValuesByInstanceSelector
|
|
.get()
|
|
.get(getInstanceKey([formId, "bodyId"]))
|
|
?.get("onResultChange")
|
|
).toBeTypeOf("function");
|
|
});
|
|
const onResultChange = $propValuesByInstanceSelector
|
|
.get()
|
|
.get(getInstanceKey([formId, "bodyId"]))
|
|
?.get("onResultChange") as (value: unknown) => void;
|
|
onResultChange(result);
|
|
const onStateChange = $propValuesByInstanceSelector
|
|
.get()
|
|
.get(getInstanceKey([formId, "bodyId"]))
|
|
?.get("onStateChange") as (state: string) => void;
|
|
expect(onStateChange).toBeTypeOf("function");
|
|
onStateChange("error");
|
|
const onProgressChange = $propValuesByInstanceSelector
|
|
.get()
|
|
.get(getInstanceKey([formId, "bodyId"]))
|
|
?.get("onProgressChange") as (value: unknown) => void;
|
|
expect(onProgressChange).toBeTypeOf("function");
|
|
const progress = {
|
|
status: "pending",
|
|
loaded: 4,
|
|
total: 12,
|
|
files: [],
|
|
};
|
|
onProgressChange(progress);
|
|
const variables = new Map(
|
|
Array.from($dataSources.get().values())
|
|
.filter((dataSource) => dataSource.type === "variable")
|
|
.map((dataSource) => [
|
|
dataSource.name,
|
|
$dataSourceVariables.get().get(dataSource.id),
|
|
])
|
|
);
|
|
expect(variables.has("status")).toBe(false);
|
|
expect(variables.get("formState")).toBe("error");
|
|
expect(variables.get("formProgress")).toBe(progress);
|
|
expect(variables.get("results")).toBe(result.results);
|
|
expect(variables.get("errors")).toBe(result.errors);
|
|
for (const child of $instances.get().get(formId)?.children ?? []) {
|
|
if (child.type !== "id") {
|
|
continue;
|
|
}
|
|
const availableNames = findAvailableVariables({
|
|
startingInstanceId: child.value,
|
|
instances: $instances.get(),
|
|
dataSources: $dataSources.get(),
|
|
}).map(({ name }) => name);
|
|
expect(availableNames).toEqual(
|
|
expect.arrayContaining(["formProgress", "results", "errors"])
|
|
);
|
|
}
|
|
for (const name of ["formData", "browserInfo"]) {
|
|
const prop = formProps.find((prop) => prop.name === name);
|
|
expect(prop?.type).toBe("parameter");
|
|
if (prop?.type === "parameter") {
|
|
expect($dataSources.get().get(prop.value)).toMatchObject({
|
|
type: "parameter",
|
|
name,
|
|
scopeInstanceId: formId,
|
|
});
|
|
}
|
|
}
|
|
const childIds = Array.from($instances.get().values())
|
|
.filter(
|
|
({ component }) =>
|
|
component === "Input" ||
|
|
component === "Textarea" ||
|
|
component === "Button"
|
|
)
|
|
.map(({ id }) => id);
|
|
expect(
|
|
Array.from($props.get().values())
|
|
.filter(
|
|
({ instanceId, name }) =>
|
|
childIds.includes(instanceId) && name === "name"
|
|
)
|
|
.map(({ value }) => value)
|
|
).toEqual(["name", "email", "subject", "message", "attachment"]);
|
|
expect(
|
|
Array.from($props.get().values()).some(
|
|
({ instanceId, name, value }) =>
|
|
childIds.includes(instanceId) &&
|
|
name === "type" &&
|
|
value === "submit"
|
|
)
|
|
).toBe(true);
|
|
|
|
expect(
|
|
await insertWebstudioComponentAt("form", {
|
|
parentSelector: ["bodyId"],
|
|
position: "end",
|
|
})
|
|
).toBe(true);
|
|
const secondFormChild = $instances.get().get("bodyId")?.children[1];
|
|
const secondFormId =
|
|
secondFormChild?.type === "id" ? secondFormChild.value : "";
|
|
expect(secondFormId).not.toBe(formId);
|
|
const secondAction = Array.from($props.get().values()).find(
|
|
({ instanceId, name }) =>
|
|
instanceId === secondFormId && name === "action"
|
|
);
|
|
expect(secondAction?.type).toBe("json");
|
|
if (secondAction?.type === "json") {
|
|
if (!isFormSubmission(secondAction.value)) {
|
|
throw new Error("Expected valid Form actions");
|
|
}
|
|
expect(secondAction.value).toHaveLength(2);
|
|
expect(
|
|
secondAction.value.map(
|
|
({ dataSourceId }: { dataSourceId: string }) =>
|
|
$dataSources.get().get(dataSourceId)?.scopeInstanceId
|
|
)
|
|
).toEqual([secondFormId, secondFormId]);
|
|
}
|
|
} finally {
|
|
$registeredTemplates.set(previousTemplates);
|
|
}
|
|
});
|
|
|
|
test("inserts component through runtime template application", async () => {
|
|
expect(
|
|
await insertWebstudioComponentAt("ResourceForm", {
|
|
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: "ResourceForm",
|
|
children: expect.any(Array),
|
|
});
|
|
});
|
|
|
|
test("reports rejected MDX insertion without changing instances", async () => {
|
|
const instances = $instances.get();
|
|
instances.get("bodyId")!.children = [{ type: "id", value: "block" }];
|
|
instances.set("block", {
|
|
type: "instance",
|
|
id: "block",
|
|
component: blockComponent,
|
|
children: [{ type: "id", value: "mdx" }],
|
|
});
|
|
instances.set("mdx", {
|
|
type: "instance",
|
|
id: "mdx",
|
|
component: blockBodyComponent,
|
|
children: [],
|
|
});
|
|
$props.set(
|
|
new Map([
|
|
[
|
|
"source",
|
|
{
|
|
id: "source",
|
|
instanceId: "block",
|
|
name: "src",
|
|
type: "asset",
|
|
value: "article",
|
|
},
|
|
],
|
|
])
|
|
);
|
|
const original = structuredClone(instances);
|
|
const notify = vi.spyOn(toast, "error");
|
|
try {
|
|
expect(
|
|
await insertWebstudioComponentAt("ResourceForm", {
|
|
parentSelector: ["mdx", "block", "bodyId"],
|
|
position: "end",
|
|
})
|
|
).toBe(false);
|
|
expect(notify).toHaveBeenCalledOnce();
|
|
expect($instances.get()).toEqual(original);
|
|
} finally {
|
|
notify.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|