mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-05 11:38:36 +09:00
## User-facing features and how to test them - Project Settings → Emails → Reply to has an info tooltip explaining how its display name is used in visitor confirmation subjects and messages, and when “Site Owner” is shown. Test it by hovering or focusing the info icon. - When a Form has no resources available for its Action, clicking the Action + button explains that a resource must first be created in Variables. Test it with a Form that has no action-capable resources, then create a resource and verify it can be added to the Action. - Success redirect is shown inside the Action control only when Resource actions are connected. Test by switching between navigation mode and a Form with a Resource action; the redirect field should appear only for Resource actions, and its saved value should return when a Resource action is added again. - The Action control no longer offers Disable/Enable. Connected Resource actions run in their configured order. The Form Action model has no `enabled` field; older saved JSON with that extra key is read as an ordinary connected action. ## Verification - Focused UI test for the Reply to tooltip. - Focused UI test for the empty Action resource tooltip. - Manually verify Success redirect visibility and value retention while switching modes. - Manually verify all connected Actions execute in order, including older saved Actions carrying an ignored `enabled` key. ## Review follow-up - Address all current open code review comments on this PR, verify the fixes, and reply to each addressed thread before resolving it. ## CI repair follow-up - Inspect the latest failing checks on this PR, fix the causes on the existing branch, and verify the affected checks locally and in CI. ## Follow-up audit (2026-10-14) - Audited head `8fb0b44`, all inline review threads and the PR conversation. The earlier Reply to tooltip comment was answered and resolved. Two P1 comments remained: required boolean `enabled` validation in CLI evaluations, and execution of saved disabled actions. - `builder-e2e (shard-6)` failed because Preview returned `Disabled` between `First` and `Last` in the configured failure results test. The failure matches the security review finding. `builder-e2e (shard-2)` was still in progress at audit start; the staging deployment workflow was cancelled. - The follow-up filters saved disabled actions from execution and restores boolean `enabled` validation in both CLI evaluations. Builder labels saved disabled actions. Focused SDK (55), CLI (222), and Builder (45) tests, plus all three affected package typechecks, passed locally. - Pushed `7c2b90e082`; replied to both P1 review threads, then resolved them. Its main CI run passed all 25 test/check jobs, including all six Builder E2E shards and the previously failing shard 6. - A later P2 review identified stale CLI/MCP guidance. Commit `160a9d1707` corrects the source and generated docs; focused docs tests (12) and CLI typecheck pass locally. Replied to and resolved that thread. On this latest head, the main CI run completed successfully: 27 check jobs passed, including all six Builder E2E shards; one visual-regression job was skipped. All three required checks passed. The automatically triggered staging deployment workflow was cancelled under the no-deployment instruction. ## Input type default follow-up - Show `text` as selected in the Props panel for both the HTML `input` element and the Input component when their `type` prop is unset, matching their rendered default. Verify both variants in the Builder and retain explicit type choices. ## Current review-thread follow-up (2026-10-14) - Recheck every unresolved review thread against the current PR head, make and validate any required fixes, and reply with the resolution before closing each thread. Keep this work on the existing PR branch; do not deploy. ## Input type metadata follow-up (2026-10-14) - Move the unset input `type` display default into component/element property metadata for both the Input component and native HTML `input` element. Remove the Props-panel special case, keep the implicit default out of saved props, and cover both variants with regression tests. - Run focused checks, push the fix to this PR, and cancel any automatically triggered staging deployment workflow. Do not deploy manually. ## Props metadata selector simplification follow-up (2026-10-15) - Refactor `$selectedInstancePropsMetas` after the input default metadata change. Preserve the current metadata, ordering, and attribute override behavior while simplifying the selector's structure and abstraction boundaries. - Run focused checks, commit and push to this PR branch. Cancel any automatically triggered staging deployment; do not deploy manually. ## Latest review follow-up (2026-10-15) - Audit the latest PR head, all unresolved inline review threads, and general PR comments. Address actionable feedback on this branch, run focused validation, then reply and resolve each addressed thread. Cancel any automatically triggered staging deployment; do not deploy manually. ## Form Action model decision (2026-10-15) - Remove the Form Action `enabled` field completely from the type, serialization, validation, execution, fixtures, and current docs. Accept older saved JSON containing that extra key by ignoring it, including `enabled: false`; action presence and valid IDs determine execution. - Update the CLI evaluation to assert intended action IDs and order without using `enabled`. Address the latest P2 review finding against this model. Run focused checks, push on this PR branch, and cancel any automatic staging deployment. No manual deployment. ## Action control visual follow-up (2026-10-15) - Match the Form Action plus button to the established Variables section plus button style and implementation where appropriate. Keep the existing icon. - Give the Action resource dropdown a stable width comparable to the Variables resource dropdown, including when available resource names are short. - Add focused UI coverage, validate locally, commit and push to this PR branch, and cancel any automatic staging deployment. Do not deploy manually. ## Action plus hover follow-up (2026-10-15) - Match the Action `+` button's hover and focus styling exactly to the plus buttons in panels such as Variables. Compare their actual implementations, preserve the existing icon, and add focused UI coverage where appropriate. - Run focused checks, commit and push to this PR branch. Cancel and confirm any automatically triggered staging workflow; do not deploy manually. ## Current CI fix (2026-10-15) - Inspect the latest check rollup and failing job logs on the current PR head. Fix all current failures on this branch, run focused local validation, push the changes, and monitor the new head until checks complete. - Cancel and confirm any automatically triggered staging deployment. Do not deploy manually.
1753 lines
52 KiB
TypeScript
1753 lines
52 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("Form");
|
|
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);
|
|
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 ["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 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("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(["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("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),
|
|
});
|
|
});
|
|
|
|
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("Form", {
|
|
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();
|
|
});
|
|
});
|