mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-01 01:28:42 +09:00
## Summary Make `Resource<Document>` the lazy document-resolution abstraction shared by Content Engine graphs, generated sites, and Builder previews. Expressions use one async computation interface: referenced resource documents resolve first, independent requests run concurrently, and unused resources stay unloaded. Ref #6029 Ref #6031 ## Implementation checklist - [x] Define one resource contract for synchronous or asynchronous document resolution. - [x] Resolve explicit roots lazily with memoization, bounded concurrency, cancellation, and structured errors. - [x] Preserve cycle, duplicate, and missing-resource diagnostics. - [x] Extract resource dependencies with the existing expression AST. - [x] Discover consumed resource roots from the current page tree and page metadata. - [x] Generate a lazy request graph for published page data, including independent requests. - [x] Resolve dependency documents before evaluating dependent request expressions. - [x] Integrate dependency-first loading with Builder preview caching and invalidation. - [x] Expose safe Resource bindings in the editor and reject self/indirect cycles. - [x] Adapt REST, GraphQL, Assets, date, and sitemap requests through the same Resource contract. - [x] Cover laziness, chains, shared dependencies, concurrency, invalidation, cancellation, failures, and cycles with fail/pass tests. - [x] Review documentation impact and document dependent Resources in the Data variables guide. - [x] Track collection-item and component-parameter-dependent resources as follow-up #6031. - [x] Regenerate and review affected fixtures. - [x] Resolve action-resource request dependencies when an action is invoked. - [x] Reproduce and fix cached form submissions and multi-root dependency-cycle hangs. - [x] Document uncached form actions and regenerate affected fixtures. - [x] Preserve single and multi-selected content until a cut successfully writes to the clipboard. - [x] Preserve boolean expression values in the search-exclusion checkbox. - [x] Cancel cut deletion if project data changes while preparing or writing the clipboard; cover content/property edits and project switches for single and multi-selection, and document the retry behavior. - [x] Validate pending cuts after the final await, immediately before deletion, and reproduce the queued-update race for single and multi-selection. ## Technical direction The persisted, revisioned `DocumentGraph` remains the Content Engine artifact. `Resource<Document>` is the execution abstraction for obtaining a document; its resolver evaluates only the dependency closure reachable from requested roots. Static and remote documents therefore differ by Resource implementation, not by expression value type. Generated pages always emit a `ResourceRequestGraph`. Each node owns its output name, dependencies, and request factory. Roots come from resource variables actually referenced by the current page's metadata, props, actions, or expression children. Unused and off-page resources remain unresolved. The loader still accepts legacy request maps for compatibility. Builder preview uses the same page-root discovery as published generation. Its existing cache remains the lifecycle boundary: cached dependency documents unlock dependent requests, while invalidation removes downstream requests until their inputs resolve again. The separate canvas frame recomputes its local plan from the synchronized cache so rendered expressions receive those documents. Action requests and their dependencies resolve when a form is submitted. Each submission sends a new action request, bypassing Webstudio's resource cache. Dependency requests still use their configured cache lifetime. ## Review simplifications - Consolidated page resource-root discovery in the SDK and reused it in Builder. - Scoped root and action discovery to the current page tree. - Removed the redundant immediate-resource helper; `Resource.resolve` already accepts `Document | Promise<Document>`. - Moved output names onto graph nodes and removed the duplicate graph-level map. - Removed unused planner output and corrected transitive cycle discovery to include only Resource data sources. - Regenerated fixtures so independent resources also use lazy graph execution. - Reused one abort-aware concurrency limiter for document sessions and Resource resolution. - Collapsed duplicate generator action scans and removed redundant generated-resource bookkeeping. - Unified legacy request maps and request graphs through one resolution path. - Shared the 20-request concurrency limit between SDK, Builder preview, and the server batch boundary. - Preserved dependency failure paths and original causes, including the first cancellation reason. - Removed invalid-request logging that could expose Resource headers or request bodies. - Made editor cycle detection alias-safe by comparing underlying Resource IDs. - Memoized page settings values to prevent repeated async validation renders. ## Verification Latest local verification for `bbca6395e3`: - Both queued-update regression cases failed before the fix and passed afterward. Validation now runs synchronously immediately before deletion, after the final await, in both cut paths. - Passed: 255 copy/paste and command tests, Builder typecheck, lint, package boundaries, formatting, and diff checks. - Reviewed documentation: the existing copy/paste safety description remains accurate. Fixture inputs and outputs are unaffected; no fixture regeneration was needed. Agent evaluations were not run because separate approval is required. - CI for the new commit is pending. Previous local verification for `ffbc75b152`: - Six regression cases failed before the fix and passed afterward: content edits, property edits, and project switches during a pending cut, with both single and multiple selections. - Passed: 253 copy/paste and command tests, Builder typecheck, lint, package boundaries, source formatting, and diff checks. - An unchanged content-block test file initially failed to import in the browser. Its isolated rerun and the full 253-test rerun passed without code changes. - The guard conservatively treats any project-data change as a reason to keep the clipboard copy without deleting content. It shows a retry message. Copy/paste documentation now describes this behavior. - Fixture inputs and outputs are unaffected; fixtures were not regenerated. Agent evaluations were not run because separate approval is required. CI for the new commit is pending. Previous local verification for `3eb580697c`: - Reproduced both regressions with failing tests before fixing them: premature deletion during cut and false search exclusion appearing checked. - Passed: 203 copy/paste and page-settings tests, Builder typecheck, lint, package boundaries, formatting, and diff checks. - Cut coverage includes keyboard and programmatic entry points, single and multiple selections, delayed successful writes, rejected writes, and disabled copying. - Reviewed documentation impact: these fixes restore existing behavior; no documentation update is needed. Only Builder UI and clipboard files changed; fixture inputs and outputs are unaffected, so fixtures were not regenerated for this commit. - CI status for this older commit was recorded as pending at handoff. Agent evaluations were not run; they require separate approval. Previous local verification for `858b348ad`, rebased onto `cf606ca4c`: - Regression tests failed before each fix and passed afterward: repeated submissions in both generated route templates, plus three multi-root/branching cycle cases. - Passed: 590 SDK tests, 1,129 CLI tests, 72 focused Builder tests, SDK/CLI/Builder typechecks, lint, package boundaries, and fixture link/sync/build. - Initial concurrent runs hit timeouts. The complete CLI suite passed with one worker; the focused Builder suites passed without file parallelism. - Documentation and generated fixture changes reviewed. Agent evaluations were not run; they require separate approval. - CI for `858b348ad` passed: Main (including all six E2E shards), Visual Regression, Lint PR, and Check submodules. Earlier verification (before this update): TDD fail/pass evidence includes resolver creation, AST dependency discovery, published graph reachability, dependency-gated request generation, Builder dependency-first planning, Resource editor cycle filtering, page-tree root scoping, off-page action isolation, transitive Resource-only cycle discovery, abort/timeout precedence, server batch limits, and the rebased SSG fixture consuming its lazy Resource. - `pnpm checks` passed after rebasing onto `ce84ddcf12`; it covered workspace tests, typechecks, lint, package boundaries, generated API/docs validation, and fixture link/sync/build. - Earlier focused checks passed: Builder typecheck, 12 page-settings/pages unit tests, lint, formatting, and `git diff --check`. - Local generated-resource E2E passed for HTTP, GraphQL, and current-date resources (3 tests). - The page-actions and Marketplace page-settings persistence E2Es both pass locally. - CI for `6b8b08a70` passed: Main workflow (including all six Builder E2E shards), Visual Regression, Lint PR, and Check submodules. - Agent evaluations were not run because they require separate explicit approval. ## Current behavior and limitations - A failed dependency blocks its dependents and reports the resource path and cause. - One published resolution call is the memoization boundary; Builder maps dependency readiness onto its versioned request-cache lifecycle. - Scoped collection-item and component-parameter resources remain in #6031. - Builder skips cyclic dependency paths without blocking independent resources.
2100 lines
66 KiB
TypeScript
2100 lines
66 KiB
TypeScript
import { afterAll, afterEach, describe, test, expect, vi } from "vitest";
|
|
import { enableMapSet } from "immer";
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import type {
|
|
Asset,
|
|
Instance,
|
|
Instances,
|
|
Prop,
|
|
Props,
|
|
DataSource,
|
|
DataSources,
|
|
Resource,
|
|
Resources,
|
|
} from "@webstudio-is/sdk";
|
|
import {
|
|
encodeDataSourceVariable,
|
|
collectionComponent,
|
|
coreMetas,
|
|
elementComponent,
|
|
blockTemplateComponent,
|
|
blockComponent,
|
|
portalComponent,
|
|
} from "@webstudio-is/sdk";
|
|
import type { Project } from "@webstudio-is/project";
|
|
import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas";
|
|
import { componentMetas } from "@webstudio-is/sdk-components-registry/metas";
|
|
import { registerContainers } from "../sync/sync-stores";
|
|
import { $registeredComponentMetas } from "../nano-states";
|
|
import {
|
|
$dataSources,
|
|
$assets,
|
|
$instances,
|
|
$pages,
|
|
$project,
|
|
$props,
|
|
$resources,
|
|
} from "~/shared/sync/data-stores";
|
|
import { instanceText } from "./plugin-instance";
|
|
import { createDefaultPages } from "@webstudio-is/project-build";
|
|
import { selectInstance, selectInstances } from "~/shared/nano-states";
|
|
import { $allSelectedInstanceSelectors } from "~/shared/nano-states";
|
|
import { $selectedPageId } from "../nano-states/pages";
|
|
import * as instanceMutationUtils from "../instance-utils/mutation";
|
|
import {
|
|
expectSlotTreeIntegrity,
|
|
expectSlotsShareFragment,
|
|
} from "../slot-test-utils";
|
|
import { pasteHandled } from "./copy-paste";
|
|
import { __testing__ as builderApiTesting } from "../builder-api";
|
|
import { $externalContentRoots } from "../external-content-mutations";
|
|
|
|
afterAll(builderApiTesting.useLocalApi());
|
|
afterEach(() => vi.restoreAllMocks());
|
|
const expectString = expect.any(String) as unknown as string;
|
|
|
|
enableMapSet();
|
|
registerContainers();
|
|
|
|
$registeredComponentMetas.set(
|
|
new Map(Object.entries({ ...baseComponentMetas, ...coreMetas }))
|
|
);
|
|
$project.set({ id: "my-project" } as Project);
|
|
$pages.set(
|
|
createDefaultPages({
|
|
homePageId: "home-page",
|
|
rootInstanceId: "body0",
|
|
})
|
|
);
|
|
$selectedPageId.set("home-page");
|
|
|
|
const setPageRoot = (rootInstanceId: Instance["id"]) => {
|
|
$pages.set(
|
|
createDefaultPages({
|
|
homePageId: "home-page",
|
|
rootInstanceId,
|
|
})
|
|
);
|
|
$selectedPageId.set("home-page");
|
|
};
|
|
|
|
const createInstance = (
|
|
id: Instance["id"],
|
|
component: string,
|
|
children: Instance["children"]
|
|
): Instance => {
|
|
return { type: "instance", id, component, children };
|
|
};
|
|
|
|
const getIdValuePair = <T extends { id: string }>(item: T) =>
|
|
[item.id, item] as const;
|
|
|
|
const toMap = <T extends { id: string }>(list: T[]) =>
|
|
new Map(list.map(getIdValuePair));
|
|
|
|
const getMapDifference = <Type extends Map<unknown, unknown>>(
|
|
left: Type,
|
|
right: Type
|
|
): Type => {
|
|
const leftSet = new Set(left.keys());
|
|
const rightSet = new Set(right.keys());
|
|
const difference = new Map() as Type;
|
|
for (const [key, value] of left) {
|
|
if (rightSet.has(key) === false) {
|
|
difference.set(key, value);
|
|
}
|
|
}
|
|
for (const [key, value] of right) {
|
|
if (leftSet.has(key) === false) {
|
|
difference.set(key, value);
|
|
}
|
|
}
|
|
return difference;
|
|
};
|
|
|
|
describe("copy and cut guards", () => {
|
|
test.each([false, true])(
|
|
"does not delete an edit queued during clipboard completion (multiple: %s)",
|
|
async (multiple) => {
|
|
setPageRoot("body0");
|
|
const roots = multiple ? ["box1", "box2"] : ["box1"];
|
|
$instances.set(
|
|
toMap([
|
|
createInstance(
|
|
"body0",
|
|
"Body",
|
|
roots.map((value) => ({ type: "id", value }))
|
|
),
|
|
...roots.map((id) =>
|
|
createInstance(id, "Box", [{ type: "text", value: "Before" }])
|
|
),
|
|
])
|
|
);
|
|
selectInstances(roots.map((id) => [id, "body0"]));
|
|
const notify = vi
|
|
.spyOn(builderApiTesting.api.toast, "info")
|
|
.mockImplementation(() => {});
|
|
let edited = false;
|
|
await instanceText.onCut(async () => {
|
|
queueMicrotask(() =>
|
|
queueMicrotask(() => {
|
|
// Like an instance mutation, an edit cannot target a deleted instance.
|
|
if ($instances.get().has("box1") === false) {
|
|
return;
|
|
}
|
|
const instances = new Map($instances.get());
|
|
instances.set(
|
|
"box1",
|
|
createInstance("box1", "Box", [
|
|
{ type: "text", value: "New edit" },
|
|
])
|
|
);
|
|
$instances.set(instances);
|
|
edited = true;
|
|
})
|
|
);
|
|
return true;
|
|
});
|
|
// Either cut finished first, or the edit must cancel the whole deletion.
|
|
expect($instances.get().get("body0")?.children).toEqual(
|
|
edited ? roots.map((value) => ({ type: "id", value })) : []
|
|
);
|
|
expect(notify).toHaveBeenCalledTimes(edited ? 1 : 0);
|
|
if (edited) {
|
|
expect($instances.get().get("box1")?.children).toEqual([
|
|
{ type: "text", value: "New edit" },
|
|
]);
|
|
}
|
|
}
|
|
);
|
|
|
|
test.each(
|
|
[false, true].flatMap((multiple) =>
|
|
["content", "property", "project"].map((change) => ({ multiple, change }))
|
|
)
|
|
)(
|
|
"preserves pending cut after $change change (multiple: $multiple)",
|
|
async ({ multiple, change }) => {
|
|
setPageRoot("body0");
|
|
const project = $project.get();
|
|
const roots = multiple ? ["box1", "box2"] : ["box1"];
|
|
$instances.set(
|
|
toMap([
|
|
createInstance(
|
|
"body0",
|
|
"Body",
|
|
roots.map((value) => ({ type: "id", value }))
|
|
),
|
|
...roots.map((id) =>
|
|
createInstance(id, "Box", [{ type: "text", value: "Before" }])
|
|
),
|
|
])
|
|
);
|
|
$props.set(new Map());
|
|
selectInstances(roots.map((id) => [id, "body0"]));
|
|
const notify = vi
|
|
.spyOn(builderApiTesting.api.toast, "info")
|
|
.mockImplementation(() => {});
|
|
let copied: string | undefined;
|
|
try {
|
|
await instanceText.onCut(async (data) => {
|
|
copied = data;
|
|
if (change === "content") {
|
|
const instances = new Map($instances.get());
|
|
instances.set(
|
|
"box1",
|
|
createInstance("box1", "Box", [
|
|
{ type: "text", value: "New edit" },
|
|
])
|
|
);
|
|
$instances.set(instances);
|
|
}
|
|
if (change === "property") {
|
|
$props.set(
|
|
new Map([
|
|
[
|
|
"prop",
|
|
{
|
|
id: "prop",
|
|
instanceId: "box1",
|
|
name: "title",
|
|
type: "string",
|
|
value: "New edit",
|
|
},
|
|
],
|
|
])
|
|
);
|
|
}
|
|
if (change === "project") {
|
|
$project.set({ id: "another-project" } as Project);
|
|
}
|
|
return true;
|
|
});
|
|
const transfer = JSON.parse(copied ?? "");
|
|
const fragment = multiple
|
|
? transfer["@webstudio/instances/v0.1"].fragment
|
|
: transfer["@webstudio/instance/v0.1"];
|
|
expect(
|
|
fragment.instances.find(
|
|
(instance: Instance) => instance.id === "box1"
|
|
).children
|
|
).toEqual([{ type: "text", value: "Before" }]);
|
|
expect($instances.get().get("body0")?.children).toEqual(
|
|
roots.map((value) => ({ type: "id", value }))
|
|
);
|
|
for (const id of roots) {
|
|
expect($instances.get().has(id)).toBe(true);
|
|
}
|
|
if (change === "content") {
|
|
expect($instances.get().get("box1")?.children).toEqual([
|
|
{ type: "text", value: "New edit" },
|
|
]);
|
|
}
|
|
if (change === "property") {
|
|
expect($props.get().get("prop")?.value).toBe("New edit");
|
|
}
|
|
expect(notify).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
$project.set(project);
|
|
$props.set(new Map());
|
|
}
|
|
}
|
|
);
|
|
|
|
test("does not copy without a selected instance", async () => {
|
|
selectInstance(undefined);
|
|
|
|
expect(instanceText.onCopy?.()).toBeUndefined();
|
|
});
|
|
|
|
test("does not cut without a selected instance", async () => {
|
|
selectInstance(undefined);
|
|
|
|
expect(instanceText.onCut?.(async () => true)).toBeUndefined();
|
|
});
|
|
|
|
test("does not copy or cut the selected root instance", async () => {
|
|
const instances: Instances = toMap([
|
|
createInstance("body0", "Body", [{ type: "id", value: "box1" }]),
|
|
createInstance("box1", "Box", []),
|
|
] satisfies Instance[]);
|
|
$instances.set(instances);
|
|
selectInstance(["body0"]);
|
|
|
|
await expect(instanceText.onCopy?.()).resolves.toBeUndefined();
|
|
await expect(
|
|
instanceText.onCut?.(async () => true)
|
|
).resolves.toBeUndefined();
|
|
expect($instances.get()).toEqual(instances);
|
|
});
|
|
|
|
test("copies multiple selected roots into one combined fragment", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
|
|
const clipboardData = await instanceText.onCopy?.();
|
|
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instances/v0.1": {
|
|
sourceOrigin: window.location.origin,
|
|
rootInstanceIds: ["box1", "box2"],
|
|
fragment: {
|
|
children: [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
],
|
|
instances: [
|
|
{ id: "box1", type: "instance", component: "Box", children: [] },
|
|
{ id: "box2", type: "instance", component: "Box", children: [] },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test("copies and pastes one rendered Collection Content Block with its resolved direct MDX source", async () => {
|
|
setPageRoot("body");
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body", "Body", [{ type: "id", value: "collection" }]),
|
|
createInstance("collection", collectionComponent, [
|
|
{ type: "id", value: "item" },
|
|
]),
|
|
createInstance("item", "Box", [{ type: "id", value: "block" }]),
|
|
createInstance("block", blockComponent, [
|
|
{ type: "id", value: "templates" },
|
|
{ type: "id", value: "mdx-heading" },
|
|
]),
|
|
createInstance("templates", blockTemplateComponent, []),
|
|
createInstance("mdx-heading", elementComponent, [
|
|
{ type: "text", value: "Loaded from MDX" },
|
|
]),
|
|
])
|
|
);
|
|
$dataSources.set(
|
|
toMap([
|
|
{
|
|
id: "item-source",
|
|
scopeInstanceId: "collection",
|
|
type: "parameter",
|
|
name: "item",
|
|
},
|
|
])
|
|
);
|
|
$props.set(
|
|
toMap([
|
|
{
|
|
id: "collection-data",
|
|
instanceId: "collection",
|
|
name: "data",
|
|
type: "json",
|
|
value: [{ mdx: "post" }],
|
|
},
|
|
{
|
|
id: "collection-item",
|
|
instanceId: "collection",
|
|
name: "item",
|
|
type: "parameter",
|
|
value: "item-source",
|
|
},
|
|
{
|
|
id: "block-source",
|
|
instanceId: "block",
|
|
name: "src",
|
|
type: "expression",
|
|
value: `${encodeDataSourceVariable("item-source")}.mdx`,
|
|
},
|
|
] satisfies Prop[])
|
|
);
|
|
$assets.set(
|
|
toMap([
|
|
{
|
|
id: "post",
|
|
projectId: "my-project",
|
|
type: "file",
|
|
format: "mdx",
|
|
name: "post.mdx",
|
|
size: 0,
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
meta: {},
|
|
},
|
|
])
|
|
);
|
|
const renderScope = '["block","item","collection[0]","collection","body"]';
|
|
$externalContentRoots.set(
|
|
new Map([
|
|
[
|
|
"post-root",
|
|
{
|
|
sourceBlockInstanceId: "block",
|
|
sourceRenderScope: renderScope,
|
|
blockInstanceId: "runtime-block",
|
|
renderScope,
|
|
instanceIds: new Set<string>(),
|
|
mutationRevision: 0,
|
|
identity: {
|
|
blockInstanceId: "block",
|
|
assetId: "post",
|
|
revision: "revision",
|
|
contentRef: "post.mdx",
|
|
format: "mdx",
|
|
renderScope,
|
|
},
|
|
},
|
|
],
|
|
])
|
|
);
|
|
selectInstance(["item", "collection[0]", "collection", "body"]);
|
|
|
|
const clipboardData = await instanceText.onCopy?.();
|
|
const fragment = JSON.parse(clipboardData ?? "")[
|
|
"@webstudio/instance/v0.1"
|
|
];
|
|
|
|
expect(fragment.props).toContainEqual({
|
|
id: "block-source",
|
|
instanceId: "block",
|
|
name: "src",
|
|
type: "asset",
|
|
value: "post",
|
|
});
|
|
expect(fragment.assets).toContainEqual(
|
|
expect.objectContaining({ id: "post", format: "mdx" })
|
|
);
|
|
expect(fragment.assetPaths).toEqual({ post: "post.mdx" });
|
|
expect(fragment.instances).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: "block" }),
|
|
expect.objectContaining({ id: "item" }),
|
|
expect.objectContaining({ id: "templates" }),
|
|
])
|
|
);
|
|
expect(fragment.instances).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ id: "mdx-heading" })])
|
|
);
|
|
const importAssets = vi
|
|
.spyOn(builderApiTesting.api, "importAssets")
|
|
.mockResolvedValue(new Map([["post", $assets.get().get("post")!]]));
|
|
$instances.set(
|
|
toMap([createInstance("target-body", "Body", [])] satisfies Instance[])
|
|
);
|
|
$props.set(new Map());
|
|
$dataSources.set(new Map());
|
|
setPageRoot("target-body");
|
|
selectInstance(["target-body"]);
|
|
|
|
expect(await instanceText.onPaste?.(clipboardData ?? "")).toEqual(
|
|
pasteHandled
|
|
);
|
|
const pastedBlock = Array.from($instances.get().values()).find(
|
|
(instance) => instance.component === blockComponent
|
|
);
|
|
expect(pastedBlock).toBeDefined();
|
|
expect(
|
|
Array.from($props.get().values()).find(
|
|
(prop) => prop.instanceId === pastedBlock?.id && prop.name === "src"
|
|
)
|
|
).toEqual(expect.objectContaining({ type: "asset", value: "post" }));
|
|
expect(
|
|
Array.from($instances.get().values()).some(
|
|
(instance) => instance.id === "mdx-heading"
|
|
)
|
|
).toBe(false);
|
|
|
|
importAssets.mockRestore();
|
|
$externalContentRoots.set(new Map());
|
|
$props.set(new Map());
|
|
$assets.set(new Map());
|
|
setPageRoot("body0");
|
|
});
|
|
|
|
test("pastes multiple copied roots in order and selects pasted roots", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
]),
|
|
createInstance("box1", "Box", [{ type: "id", value: "child" }]),
|
|
createInstance("child", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["body0"]);
|
|
|
|
const result = await instanceText.onPaste?.(clipboardData);
|
|
expect(result).toEqual(pasteHandled);
|
|
|
|
const bodyChildren = $instances.get().get("body0")?.children;
|
|
const pastedRootIds = bodyChildren?.slice(2).map((child) => child.value);
|
|
expect(bodyChildren).toEqual([
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: expect.any(String) },
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
expect(pastedRootIds?.[0]).not.toBe("box1");
|
|
expect(pastedRootIds?.[1]).not.toBe("box2");
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([
|
|
[pastedRootIds?.[0], "body0"],
|
|
[pastedRootIds?.[1], "body0"],
|
|
]);
|
|
});
|
|
|
|
test("transfers assets from another deployment before inserting", async () => {
|
|
$instances.set(
|
|
toMap([createInstance("body0", "Body", [])] satisfies Instance[])
|
|
);
|
|
$assets.set(new Map());
|
|
selectInstance(["body0"]);
|
|
const sourceAsset = {
|
|
id: "source-asset",
|
|
projectId: "source-project",
|
|
name: "hero.png",
|
|
type: "image",
|
|
size: 128,
|
|
format: "png",
|
|
createdAt: "2026-01-01T00:00:00.000Z",
|
|
description: null,
|
|
meta: { width: 1200, height: 800 },
|
|
} satisfies Asset;
|
|
const importedAsset = {
|
|
...sourceAsset,
|
|
id: "imported-asset",
|
|
projectId: "my-project",
|
|
} satisfies Asset;
|
|
const importAssets = vi
|
|
.spyOn(builderApiTesting.api, "importAssets")
|
|
.mockRejectedValueOnce(new Error("Source is unavailable"))
|
|
.mockImplementation(
|
|
async () => new Map([[sourceAsset.id, importedAsset]])
|
|
);
|
|
const clipboardData = JSON.stringify({
|
|
"@webstudio/instance/v0.1": {
|
|
sourceOrigin: "https://source.example.com",
|
|
instanceSelector: ["image", "source-body"],
|
|
children: [{ type: "id", value: "image" }],
|
|
instances: [createInstance("image", "Image", [])],
|
|
assets: [sourceAsset],
|
|
dataSources: [],
|
|
resources: [],
|
|
props: [
|
|
{
|
|
id: "image-src",
|
|
instanceId: "image",
|
|
name: "src",
|
|
type: "asset",
|
|
value: sourceAsset.id,
|
|
},
|
|
],
|
|
breakpoints: [],
|
|
styleSourceSelections: [],
|
|
styleSources: [],
|
|
styles: [],
|
|
},
|
|
});
|
|
|
|
expect(await instanceText.onPaste?.(clipboardData)).toEqual({
|
|
success: false,
|
|
error:
|
|
"Could not transfer assets from the source Webstudio deployment. Make sure it is reachable and try again.",
|
|
});
|
|
expect($instances.get().get("body0")?.children).toEqual([]);
|
|
|
|
expect(await instanceText.onPaste?.(clipboardData)).toEqual(pasteHandled);
|
|
expect($instances.get().get("body0")?.children).toEqual([
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
expect(importAssets).toHaveBeenLastCalledWith("my-project", [
|
|
{
|
|
asset: sourceAsset,
|
|
url: "https://source.example.com/cgi/image/hero.png?format=raw",
|
|
},
|
|
]);
|
|
expect(Array.from($props.get().values())).toEqual([
|
|
expect.objectContaining({ type: "asset", value: importedAsset.id }),
|
|
]);
|
|
});
|
|
|
|
test("pastes a legacy invalid subtree with a warning", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "legacy-button" },
|
|
]),
|
|
{
|
|
...createInstance("legacy-button", elementComponent, [
|
|
{ type: "id", value: "legacy-heading" },
|
|
]),
|
|
tag: "button",
|
|
},
|
|
{
|
|
...createInstance("legacy-heading", elementComponent, []),
|
|
tag: "h3",
|
|
},
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["legacy-button", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["body0"]);
|
|
const warn = vi
|
|
.spyOn(builderApiTesting.api.toast, "warn")
|
|
.mockImplementation(() => {});
|
|
|
|
expect(await instanceText.onPaste?.(clipboardData)).toEqual(pasteHandled);
|
|
expect($instances.get().get("body0")?.children).toHaveLength(2);
|
|
expect(warn).toHaveBeenCalledWith(
|
|
"Pasted with warning: Placing <h3> element inside a <button> violates HTML spec."
|
|
);
|
|
});
|
|
|
|
test("sanitizes multi-root clipboard root ids before paste", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
const clipboardData = JSON.parse((await instanceText.onCopy?.()) ?? "");
|
|
clipboardData["@webstudio/instances/v0.1"].rootInstanceIds = [
|
|
"box1",
|
|
"child",
|
|
"missing",
|
|
"box1",
|
|
];
|
|
selectInstance(["body0"]);
|
|
|
|
expect(await instanceText.onPaste?.(JSON.stringify(clipboardData))).toEqual(
|
|
pasteHandled
|
|
);
|
|
|
|
const bodyChildren = $instances.get().get("body0")?.children;
|
|
expect(bodyChildren).toEqual([
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
|
|
clipboardData["@webstudio/instances/v0.1"].rootInstanceIds = ["missing"];
|
|
expect(await instanceText.onPaste?.(JSON.stringify(clipboardData))).toEqual(
|
|
pasteHandled
|
|
);
|
|
expect($instances.get().get("body0")?.children).toEqual(bodyChildren);
|
|
});
|
|
|
|
test("writes only copyable roots when multi-copy skips selected roots", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "template" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("template", blockTemplateComponent, []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["template", "body0"],
|
|
["box1", "body0"],
|
|
]);
|
|
|
|
const clipboardData = await instanceText.onCopy?.();
|
|
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instances/v0.1": {
|
|
rootInstanceIds: ["box1"],
|
|
},
|
|
});
|
|
});
|
|
|
|
test("does not copy when no multi-selected root is copyable", async () => {
|
|
const instances: Instances = toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "template1" },
|
|
{ type: "id", value: "template2" },
|
|
]),
|
|
createInstance("template1", blockTemplateComponent, []),
|
|
createInstance("template2", blockTemplateComponent, []),
|
|
] satisfies Instance[]);
|
|
$instances.set(instances);
|
|
selectInstances([
|
|
["template1", "body0"],
|
|
["template2", "body0"],
|
|
]);
|
|
|
|
await expect(instanceText.onCopy?.()).resolves.toBeUndefined();
|
|
expect($instances.get()).toEqual(instances);
|
|
});
|
|
|
|
test("cuts multiple selected roots into one combined fragment and removes them", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
|
|
const clipboardData = await instanceText.onCut?.(async () => true);
|
|
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instances/v0.1": {
|
|
rootInstanceIds: ["box1", "box2"],
|
|
},
|
|
});
|
|
expect($instances.get().get("body0")?.children).toEqual([]);
|
|
expect($instances.get().has("box1")).toBe(false);
|
|
expect($instances.get().has("box2")).toBe(false);
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([]);
|
|
});
|
|
|
|
test("cuts only copyable roots when multi-cut skips selected roots", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "template" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("template", blockTemplateComponent, []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["template", "body0"],
|
|
["box1", "body0"],
|
|
]);
|
|
|
|
const clipboardData = await instanceText.onCut?.(async () => true);
|
|
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instances/v0.1": {
|
|
rootInstanceIds: ["box1"],
|
|
},
|
|
});
|
|
expect($instances.get().get("body0")?.children).toEqual([
|
|
{ type: "id", value: "template" },
|
|
]);
|
|
expect($instances.get().has("box1")).toBe(false);
|
|
expect($instances.get().has("template")).toBe(true);
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([]);
|
|
});
|
|
|
|
test("does not cut when no multi-selected root is copyable", async () => {
|
|
const instances: Instances = toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "template1" },
|
|
{ type: "id", value: "template2" },
|
|
]),
|
|
createInstance("template1", blockTemplateComponent, []),
|
|
createInstance("template2", blockTemplateComponent, []),
|
|
] satisfies Instance[]);
|
|
$instances.set(instances);
|
|
selectInstances([
|
|
["template1", "body0"],
|
|
["template2", "body0"],
|
|
]);
|
|
|
|
await expect(
|
|
instanceText.onCut?.(async () => true)
|
|
).resolves.toBeUndefined();
|
|
expect($instances.get()).toEqual(instances);
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([
|
|
["template1", "body0"],
|
|
["template2", "body0"],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("paste target", () => {
|
|
test("pastes radix navigation menu fragment with viewport wrapper", async () => {
|
|
const previousMetas = $registeredComponentMetas.get();
|
|
$registeredComponentMetas.set(componentMetas);
|
|
$instances.set(toMap([createInstance("body0", "Body", [])]));
|
|
selectInstance(["body0"]);
|
|
|
|
const clipboardData = JSON.stringify({
|
|
"@webstudio/instance/v0.1": {
|
|
instanceSelector: ["nav", "source-parent"],
|
|
children: [{ type: "id", value: "nav" }],
|
|
instances: [
|
|
createInstance(
|
|
"nav",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenu",
|
|
[
|
|
{ type: "id", value: "list" },
|
|
{ type: "id", value: "viewport-container" },
|
|
]
|
|
),
|
|
createInstance(
|
|
"list",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenuList",
|
|
[{ type: "id", value: "item" }]
|
|
),
|
|
createInstance(
|
|
"item",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenuItem",
|
|
[
|
|
{ type: "id", value: "trigger" },
|
|
{ type: "id", value: "content" },
|
|
]
|
|
),
|
|
createInstance(
|
|
"trigger",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenuTrigger",
|
|
[{ type: "id", value: "button" }]
|
|
),
|
|
createInstance("button", "Button", [{ type: "id", value: "text" }]),
|
|
createInstance("text", "Text", [{ type: "text", value: "About" }]),
|
|
createInstance(
|
|
"content",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenuContent",
|
|
[{ type: "id", value: "content-box" }]
|
|
),
|
|
createInstance("content-box", "Box", [
|
|
{ type: "id", value: "content-link" },
|
|
]),
|
|
createInstance("content-link", "Link", [
|
|
{ type: "text", value: "Documentation" },
|
|
]),
|
|
{
|
|
...createInstance("viewport-container", "Box", [
|
|
{ type: "id", value: "viewport" },
|
|
]),
|
|
label: "Viewport Container",
|
|
},
|
|
createInstance(
|
|
"viewport",
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenuViewport",
|
|
[]
|
|
),
|
|
],
|
|
assets: [],
|
|
dataSources: [],
|
|
resources: [],
|
|
props: [],
|
|
breakpoints: [],
|
|
styleSourceSelections: [],
|
|
styleSources: [],
|
|
styles: [],
|
|
},
|
|
});
|
|
|
|
const warn = vi.spyOn(toast, "warn").mockImplementation(() => "");
|
|
const result = await instanceText.onPaste?.(clipboardData);
|
|
expect(result).toEqual(pasteHandled);
|
|
expect(warn).not.toHaveBeenCalled();
|
|
|
|
const [pastedNavId] =
|
|
$instances
|
|
.get()
|
|
.get("body0")
|
|
?.children.map((child) => child.value) ?? [];
|
|
expect($instances.get().get(pastedNavId ?? "")?.component).toBe(
|
|
"@webstudio-is/sdk-components-react-radix:NavigationMenu"
|
|
);
|
|
warn.mockRestore();
|
|
$registeredComponentMetas.set(previousMetas);
|
|
});
|
|
|
|
// body0
|
|
// box1
|
|
// box2
|
|
const instances: Instances = toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
]),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[]);
|
|
|
|
test("is inside selected instance", async () => {
|
|
$instances.set(instances);
|
|
selectInstance(["box1", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["box2", "body0"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const instancesDifference = getMapDifference(instances, $instances.get());
|
|
const [newBox1] = instancesDifference.keys();
|
|
expect($instances.get().get("box2")).toEqual(
|
|
createInstance("box2", "Box", [{ type: "id", value: newBox1 }])
|
|
);
|
|
expect(instancesDifference).toEqual(
|
|
toMap([createInstance(newBox1, "Box", [])])
|
|
);
|
|
});
|
|
|
|
test("is after selected instance when same as copied", async () => {
|
|
$instances.set(instances);
|
|
selectInstance(["box1", "body0"]);
|
|
await instanceText.onPaste?.((await instanceText.onCopy?.()) ?? "");
|
|
|
|
const instancesDifference = getMapDifference(instances, $instances.get());
|
|
const [newBox1] = instancesDifference.keys();
|
|
expect($instances.get().get("body0")).toEqual(
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: newBox1 },
|
|
{ type: "id", value: "box2" },
|
|
])
|
|
);
|
|
expect(instancesDifference).toEqual(
|
|
toMap([createInstance(newBox1, "Box", [])])
|
|
);
|
|
});
|
|
|
|
test("uses multi-selection paste target after the last selected sibling for old single-root clipboard data", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: "tail" },
|
|
]),
|
|
createInstance("source", "Box", []),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
createInstance("tail", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["source", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const bodyChildren = $instances.get().get("body0")?.children;
|
|
expect(bodyChildren).toEqual([
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: expect.any(String) },
|
|
{ type: "id", value: "tail" },
|
|
]);
|
|
const pastedId = bodyChildren?.[3]?.value;
|
|
expect(pastedId).not.toBe("source");
|
|
expect($allSelectedInstanceSelectors.get()).toEqual([[pastedId, "body0"]]);
|
|
});
|
|
|
|
test("uses multi-selection paste target after the last selected sibling for multi-root clipboard data", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source1" },
|
|
{ type: "id", value: "source2" },
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: "tail" },
|
|
]),
|
|
createInstance("source1", "Box", []),
|
|
createInstance("source2", "Box", []),
|
|
createInstance("box1", "Box", []),
|
|
createInstance("box2", "Box", []),
|
|
createInstance("tail", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstances([
|
|
["source1", "body0"],
|
|
["source2", "body0"],
|
|
]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstances([
|
|
["box1", "body0"],
|
|
["box2", "body0"],
|
|
]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const bodyChildren = $instances.get().get("body0")?.children;
|
|
expect(bodyChildren).toEqual([
|
|
{ type: "id", value: "source1" },
|
|
{ type: "id", value: "source2" },
|
|
{ type: "id", value: "box1" },
|
|
{ type: "id", value: "box2" },
|
|
{ type: "id", value: expect.any(String) },
|
|
{ type: "id", value: expect.any(String) },
|
|
{ type: "id", value: "tail" },
|
|
]);
|
|
});
|
|
|
|
test("uses lowest common ancestor paste target for mixed-parent multi-selection", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "section" },
|
|
{ type: "id", value: "footer" },
|
|
]),
|
|
createInstance("source", "Box", []),
|
|
createInstance("section", "Box", [
|
|
{ type: "id", value: "group" },
|
|
{ type: "id", value: "aside" },
|
|
{ type: "id", value: "tail" },
|
|
]),
|
|
createInstance("group", "Box", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("aside", "Box", []),
|
|
createInstance("tail", "Box", []),
|
|
createInstance("footer", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["source", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstances([
|
|
["box", "group", "section", "body0"],
|
|
["aside", "section", "body0"],
|
|
]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expect($instances.get().get("section")?.children).toEqual([
|
|
{ type: "id", value: "group" },
|
|
{ type: "id", value: "aside" },
|
|
{ type: "id", value: "tail" },
|
|
{ type: "id", value: expect.any(String) },
|
|
]);
|
|
expect($instances.get().get("body0")?.children).toEqual([
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "section" },
|
|
{ type: "id", value: "footer" },
|
|
]);
|
|
});
|
|
|
|
test("pastes into shared slot content", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("source", "Box", []),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["source", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
|
|
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("pastes into legacy shared slot content", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("source", "Box", []),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "box" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["source", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
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("pastes after selected shared slot child in shared slot content", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "fragment", "slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.((await instanceText.onCopy?.()) ?? "");
|
|
|
|
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("pastes after selected legacy shared slot child in shared slot content", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("slot2", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("heading", "Heading", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.((await instanceText.onCopy?.()) ?? "");
|
|
|
|
const fragmentId = expectSlotsShareFragment($instances.get(), [
|
|
"slot1",
|
|
"slot2",
|
|
]);
|
|
expect($instances.get().get(fragmentId ?? "")?.children).toEqual([
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: expect.any(String) },
|
|
{ type: "id", value: "heading" },
|
|
]);
|
|
expectSlotTreeIntegrity($instances.get());
|
|
});
|
|
|
|
test("pastes after selected nested shared slot child in shared slot content", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
|
|
createInstance("div", "Box", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "div", "fragment", "slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.((await instanceText.onCopy?.()) ?? "");
|
|
|
|
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) },
|
|
]);
|
|
});
|
|
|
|
test("pastes into nested shared slot container", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "source" },
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("source", "Box", []),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
|
|
createInstance("div", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["source", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["div", "fragment", "slot1", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
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: expect.any(String) },
|
|
]);
|
|
});
|
|
|
|
test("copies shared slot child and pastes independent copy outside", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
{ type: "id", value: "target" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("target", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "fragment", "slot1", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["target", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
|
|
const pastedBoxId = $instances.get().get("target")?.children[0]?.value;
|
|
expect(pastedBoxId).toEqual(expect.any(String));
|
|
expect(pastedBoxId).not.toBe("box");
|
|
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" },
|
|
]);
|
|
expect($instances.get().get(pastedBoxId ?? "")?.children).toEqual([]);
|
|
});
|
|
|
|
test("copies legacy shared slot child and pastes independent copy outside", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
{ type: "id", value: "target" },
|
|
]),
|
|
createInstance("slot1", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("slot2", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("heading", "Heading", []),
|
|
createInstance("target", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "slot1", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["target", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const pastedBoxId = $instances.get().get("target")?.children[0]?.value;
|
|
expect(pastedBoxId).toEqual(expect.any(String));
|
|
expect(pastedBoxId).not.toBe("box");
|
|
expect($instances.get().get(pastedBoxId ?? "")?.component).toBe("Box");
|
|
|
|
selectInstance(["box", "slot1", "body0"]);
|
|
instanceMutationUtils.convertInstance(elementComponent, "section");
|
|
|
|
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
|
|
expect($instances.get().get("box")?.component).toBe(elementComponent);
|
|
expect($instances.get().get("box")?.tag).toBe("section");
|
|
expect($instances.get().get(pastedBoxId ?? "")?.component).toBe("Box");
|
|
});
|
|
|
|
test("copies nested shared slot child with scoped data and pastes independent copy outside", async () => {
|
|
const dataSources: DataSources = toMap([
|
|
{
|
|
id: "boxVariable",
|
|
scopeInstanceId: "box",
|
|
type: "variable",
|
|
name: "Box Variable",
|
|
value: { type: "string", value: "value" },
|
|
},
|
|
] satisfies DataSource[]);
|
|
const props: Props = toMap([
|
|
{
|
|
id: "boxExpressionProp",
|
|
instanceId: "box",
|
|
name: "children",
|
|
type: "expression",
|
|
value: "$ws$dataSource$boxVariable",
|
|
},
|
|
{
|
|
id: "boxResourceProp",
|
|
instanceId: "box",
|
|
name: "resource",
|
|
type: "resource",
|
|
value: "boxResource",
|
|
},
|
|
] satisfies Prop[]);
|
|
const resources: Resources = toMap([
|
|
{
|
|
id: "boxResource",
|
|
name: "Box Resource",
|
|
method: "get",
|
|
url: "$ws$dataSource$boxVariable",
|
|
headers: [
|
|
{
|
|
name: "authorization",
|
|
value: "$ws$dataSource$boxVariable",
|
|
},
|
|
],
|
|
searchParams: [
|
|
{
|
|
name: "query",
|
|
value: "$ws$dataSource$boxVariable",
|
|
},
|
|
],
|
|
},
|
|
] satisfies Resource[]);
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
{ type: "id", value: "target" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
|
|
createInstance("div", "Box", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("target", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
$dataSources.set(dataSources);
|
|
$props.set(props);
|
|
$resources.set(resources);
|
|
selectInstance(["box", "div", "fragment", "slot1", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["target", "body0"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const pastedBoxId = $instances.get().get("target")?.children[0]?.value;
|
|
const dataSourcesDifference = getMapDifference(
|
|
dataSources,
|
|
$dataSources.get()
|
|
);
|
|
const [newDataSourceId] = dataSourcesDifference.keys();
|
|
const propsDifference = getMapDifference(props, $props.get());
|
|
const resourcesDifference = getMapDifference(resources, $resources.get());
|
|
const [newResourceId] = resourcesDifference.keys();
|
|
expect(pastedBoxId).toEqual(expect.any(String));
|
|
expect(pastedBoxId).not.toBe("box");
|
|
expect(dataSourcesDifference).toEqual(
|
|
toMap([
|
|
{
|
|
...dataSources.get("boxVariable"),
|
|
id: newDataSourceId,
|
|
scopeInstanceId: pastedBoxId,
|
|
},
|
|
])
|
|
);
|
|
expect(Array.from(propsDifference.values())).toEqual(
|
|
expect.arrayContaining([
|
|
{
|
|
id: expectString,
|
|
instanceId: pastedBoxId,
|
|
name: "children",
|
|
type: "expression",
|
|
value: encodeDataSourceVariable(newDataSourceId),
|
|
},
|
|
{
|
|
id: expectString,
|
|
instanceId: pastedBoxId,
|
|
name: "resource",
|
|
type: "resource",
|
|
value: newResourceId,
|
|
},
|
|
])
|
|
);
|
|
expect(resourcesDifference).toEqual(
|
|
toMap([
|
|
{
|
|
id: newResourceId,
|
|
name: "Box Resource",
|
|
method: "get",
|
|
url: encodeDataSourceVariable(newDataSourceId),
|
|
headers: [
|
|
{
|
|
name: "authorization",
|
|
value: encodeDataSourceVariable(newDataSourceId),
|
|
},
|
|
],
|
|
searchParams: [
|
|
{
|
|
name: "query",
|
|
value: encodeDataSourceVariable(newDataSourceId),
|
|
},
|
|
],
|
|
},
|
|
])
|
|
);
|
|
expect($instances.get().get("fragment")?.children).toEqual([
|
|
{ type: "id", value: "div" },
|
|
]);
|
|
expect($instances.get().get("div")?.children).toEqual([
|
|
{ type: "id", value: "box" },
|
|
]);
|
|
});
|
|
|
|
test("cuts shared slot child from all slot occurrences", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "fragment", "slot1", "body0"]);
|
|
|
|
const clipboardData = await instanceText.onCut?.(async () => true);
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instance/v0.1": {
|
|
instanceSelector: ["box", "fragment", "slot1", "body0"],
|
|
children: [{ type: "id", value: "box" }],
|
|
},
|
|
});
|
|
|
|
expectSlotsShareFragment($instances.get(), ["slot1", "slot2"]);
|
|
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([]);
|
|
expect($instances.get().has("box")).toBe(false);
|
|
});
|
|
|
|
test("cuts legacy shared slot child from all slot occurrences", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("slot2", "Slot", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("heading", "Heading", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "slot1", "body0"]);
|
|
|
|
const clipboardData = await instanceText.onCut?.(async () => true);
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instance/v0.1": {
|
|
instanceSelector: ["box", "slot1", "body0"],
|
|
children: [{ type: "id", value: "box" }],
|
|
},
|
|
});
|
|
|
|
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: "heading" },
|
|
]);
|
|
expect($instances.get().has("box")).toBe(false);
|
|
});
|
|
|
|
test("cuts nested shared slot child from all slot occurrences", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "div" }]),
|
|
createInstance("div", "Box", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "div", "fragment", "slot1", "body0"]);
|
|
|
|
const clipboardData = await instanceText.onCut?.(async () => true);
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instance/v0.1": {
|
|
instanceSelector: ["box", "div", "fragment", "slot1", "body0"],
|
|
children: [{ type: "id", value: "box" }],
|
|
},
|
|
});
|
|
|
|
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([]);
|
|
expect($instances.get().has("box")).toBe(false);
|
|
});
|
|
|
|
test("cuts shared slot child and preserves shared siblings", async () => {
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body0", "Body", [
|
|
{ type: "id", value: "slot1" },
|
|
{ type: "id", value: "slot2" },
|
|
]),
|
|
createInstance("slot1", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("slot2", "Slot", [{ type: "id", value: "fragment" }]),
|
|
createInstance("fragment", "Fragment", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: "heading" },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance("heading", "Heading", []),
|
|
] satisfies Instance[])
|
|
);
|
|
selectInstance(["box", "fragment", "slot1", "body0"]);
|
|
|
|
const clipboardData = await instanceText.onCut?.(async () => true);
|
|
expect(JSON.parse(clipboardData ?? "")).toMatchObject({
|
|
"@webstudio/instance/v0.1": {
|
|
instanceSelector: ["box", "fragment", "slot1", "body0"],
|
|
children: [{ type: "id", value: "box" }],
|
|
},
|
|
});
|
|
|
|
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: "heading" },
|
|
]);
|
|
expect($instances.get().has("box")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("data sources", () => {
|
|
// body0
|
|
// box1
|
|
// box2
|
|
const instances: Instances = toMap([
|
|
createInstance("body0", "Body", [{ type: "id", value: "box1" }]),
|
|
createInstance("box1", "Box", [{ type: "id", value: "box2" }]),
|
|
createInstance("box2", "Box", []),
|
|
] satisfies Instance[]);
|
|
const dataSources: DataSources = toMap([
|
|
{
|
|
id: "box1$state",
|
|
scopeInstanceId: "box1",
|
|
type: "variable",
|
|
name: "state",
|
|
value: { type: "string", value: "initial" },
|
|
},
|
|
] satisfies DataSource[]);
|
|
const props: Props = toMap([
|
|
{
|
|
id: "box1$stateProp",
|
|
instanceId: "box1",
|
|
name: "state",
|
|
type: "expression",
|
|
value: "$ws$dataSource$box1$state",
|
|
},
|
|
{
|
|
id: "box2$stateProp",
|
|
instanceId: "box2",
|
|
name: "state",
|
|
type: "expression",
|
|
value: "$ws$dataSource$box1$state",
|
|
},
|
|
{
|
|
id: "box2$showProp",
|
|
instanceId: "box2",
|
|
name: "show",
|
|
type: "expression",
|
|
value: `$ws$dataSource$box1$state === 'initial'`,
|
|
},
|
|
{
|
|
id: "box2$onChangeProp",
|
|
instanceId: "box2",
|
|
type: "action",
|
|
name: "onChange",
|
|
value: [
|
|
{
|
|
type: "execute",
|
|
args: ["value"],
|
|
code: `$ws$dataSource$box1$state = value`,
|
|
},
|
|
],
|
|
},
|
|
] satisfies Prop[]);
|
|
|
|
test("are copy pasted when scoped to copied instances", async () => {
|
|
$instances.set(instances);
|
|
$dataSources.set(dataSources);
|
|
$props.set(props);
|
|
selectInstance(["box1", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["body0"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const instancesDifference = getMapDifference(instances, $instances.get());
|
|
const [newBox1, newBox2] = instancesDifference.keys();
|
|
|
|
const dataSourcesDifference = getMapDifference(
|
|
dataSources,
|
|
$dataSources.get()
|
|
);
|
|
const [newDataSource1] = dataSourcesDifference.keys();
|
|
expect(dataSourcesDifference).toEqual(
|
|
toMap([
|
|
{
|
|
...dataSources.get("box1$state"),
|
|
id: newDataSource1,
|
|
scopeInstanceId: newBox1,
|
|
},
|
|
])
|
|
);
|
|
|
|
const propsDifference = getMapDifference(props, $props.get());
|
|
const [newProp1, newProp2, newProp3, newProp4] = propsDifference.keys();
|
|
expect(propsDifference).toEqual(
|
|
toMap([
|
|
{
|
|
id: newProp1,
|
|
instanceId: newBox1,
|
|
name: "state",
|
|
type: "expression",
|
|
value: encodeDataSourceVariable(newDataSource1),
|
|
},
|
|
{
|
|
id: newProp2,
|
|
instanceId: newBox2,
|
|
name: "state",
|
|
type: "expression",
|
|
value: encodeDataSourceVariable(newDataSource1),
|
|
},
|
|
{
|
|
id: newProp3,
|
|
instanceId: newBox2,
|
|
name: "show",
|
|
type: "expression",
|
|
value: `${encodeDataSourceVariable(newDataSource1)} === 'initial'`,
|
|
},
|
|
{
|
|
id: newProp4,
|
|
instanceId: newBox2,
|
|
name: "onChange",
|
|
type: "action",
|
|
value: [
|
|
{
|
|
type: "execute",
|
|
args: ["value"],
|
|
code: `${encodeDataSourceVariable(newDataSource1)} = value`,
|
|
},
|
|
],
|
|
},
|
|
])
|
|
);
|
|
});
|
|
|
|
test("preserve data sources outside of scope when pasted within their scope", async () => {
|
|
$instances.set(instances);
|
|
$props.set(props);
|
|
$dataSources.set(dataSources);
|
|
selectInstance(["box2", "box1", "body0"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["box1", "body0"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const instancesDifference = getMapDifference(instances, $instances.get());
|
|
const [newBox2] = instancesDifference.keys();
|
|
|
|
const dataSourcesDifference = getMapDifference(
|
|
dataSources,
|
|
$dataSources.get()
|
|
);
|
|
expect(dataSourcesDifference).toEqual(new Map());
|
|
|
|
const propsDifference = getMapDifference(props, $props.get());
|
|
const [newProp1, newProp2, newProp3] = propsDifference.keys();
|
|
expect(propsDifference).toEqual(
|
|
toMap([
|
|
{
|
|
id: newProp1,
|
|
instanceId: newBox2,
|
|
name: "state",
|
|
type: "expression",
|
|
value: `$ws$dataSource$box1$state`,
|
|
},
|
|
{
|
|
id: newProp2,
|
|
instanceId: newBox2,
|
|
name: "show",
|
|
type: "expression",
|
|
value: `$ws$dataSource$box1$state === 'initial'`,
|
|
},
|
|
{
|
|
id: newProp3,
|
|
instanceId: newBox2,
|
|
type: "action",
|
|
name: "onChange",
|
|
value: [
|
|
{
|
|
args: ["value"],
|
|
code: "$ws$dataSource$box1$state = value",
|
|
type: "execute",
|
|
},
|
|
],
|
|
},
|
|
])
|
|
);
|
|
});
|
|
|
|
test("copy parameter prop with new data source", async () => {
|
|
setPageRoot("body");
|
|
const instances: Instances = toMap([
|
|
createInstance("body", "Body", [{ type: "id", value: "list" }]),
|
|
createInstance("list", collectionComponent, []),
|
|
] satisfies Instance[]);
|
|
const dataSources: DataSources = toMap([
|
|
{
|
|
id: "itemDataSource",
|
|
scopeInstanceId: "list",
|
|
type: "parameter",
|
|
name: "item",
|
|
},
|
|
] satisfies DataSource[]);
|
|
const props: Props = toMap([
|
|
{
|
|
id: "dataProp",
|
|
instanceId: "list",
|
|
name: "data",
|
|
type: "json",
|
|
value: [],
|
|
},
|
|
{
|
|
id: "itemProp",
|
|
instanceId: "list",
|
|
name: "item",
|
|
type: "parameter",
|
|
value: "itemDataSource",
|
|
},
|
|
] satisfies Prop[]);
|
|
$instances.set(instances);
|
|
$props.set(props);
|
|
$dataSources.set(dataSources);
|
|
selectInstance(["list", "body"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["body"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const instancesDifference = getMapDifference(instances, $instances.get());
|
|
const [collectionId] = instancesDifference.keys();
|
|
|
|
const dataSourcesDifference = getMapDifference(
|
|
dataSources,
|
|
$dataSources.get()
|
|
);
|
|
const [itemDataSourceId] = dataSourcesDifference.keys();
|
|
expect(dataSourcesDifference).toEqual(
|
|
new Map([
|
|
[
|
|
itemDataSourceId,
|
|
{
|
|
id: itemDataSourceId,
|
|
scopeInstanceId: collectionId,
|
|
type: "parameter",
|
|
name: "item",
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
const propsDifference = getMapDifference(props, $props.get());
|
|
const [dataPropId, itemPropId] = propsDifference.keys();
|
|
expect(propsDifference).toEqual(
|
|
toMap([
|
|
{
|
|
id: dataPropId,
|
|
instanceId: collectionId,
|
|
name: "data",
|
|
type: "json",
|
|
value: [],
|
|
},
|
|
{
|
|
id: itemPropId,
|
|
instanceId: collectionId,
|
|
name: "item",
|
|
type: "parameter",
|
|
value: itemDataSourceId,
|
|
},
|
|
])
|
|
);
|
|
});
|
|
});
|
|
|
|
test("when paste into copied instance insert after it", async () => {
|
|
setPageRoot("body");
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body", "Body", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
])
|
|
);
|
|
selectInstance(["box", "body"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expect($instances.get()).toEqual(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "box" },
|
|
{ type: "id", value: expectString },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance(expectString, "Box", []),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("prevent pasting portal into own descendants", async () => {
|
|
const instances = toMap([
|
|
createInstance("body", "Body", [{ type: "id", value: "portal" }]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("fragment", "Fragment", [{ type: "id", value: "box" }]),
|
|
createInstance("box", "Box", []),
|
|
]);
|
|
$instances.set(instances);
|
|
selectInstance(["portal", "body"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["fragment", "portal", "body"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
expect($instances.get()).toEqual(instances);
|
|
});
|
|
|
|
test("prevent pasting portal into copy of it", async () => {
|
|
const instances = toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal1" },
|
|
{ type: "id", value: "portal2" },
|
|
]),
|
|
createInstance("portal1", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("portal2", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("fragment", "Fragment", []),
|
|
]);
|
|
$instances.set(instances);
|
|
selectInstance(["portal1", "body"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["portal2", "body"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
expect($instances.get()).toEqual(instances);
|
|
});
|
|
|
|
test("insert portal into its sibling", async () => {
|
|
setPageRoot("body");
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "sibling" },
|
|
]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("fragment", "Fragment", []),
|
|
createInstance("sibling", "Box", []),
|
|
])
|
|
);
|
|
selectInstance(["portal", "body"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["sibling", "body"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
expect($instances.get()).toEqual(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "sibling" },
|
|
]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("fragment", "Fragment", []),
|
|
createInstance("sibling", "Box", [{ type: "id", value: expectString }]),
|
|
createInstance(expectString, portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
])
|
|
);
|
|
});
|
|
|
|
test("inserts multi-root clipboard into descendant of copied non-portal root", async () => {
|
|
setPageRoot("body");
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "box" },
|
|
]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: "fragment" },
|
|
]),
|
|
createInstance("fragment", "Fragment", []),
|
|
createInstance("box", "Box", [{ type: "id", value: "target" }]),
|
|
createInstance("target", "Box", []),
|
|
])
|
|
);
|
|
selectInstances([
|
|
["portal", "body"],
|
|
["box", "body"],
|
|
]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["target", "box", "body"]);
|
|
|
|
await instanceText.onPaste?.(clipboardData);
|
|
|
|
const targetChildren = $instances.get().get("target")?.children;
|
|
const [pastedPortalId, pastedBoxId] =
|
|
targetChildren?.map((child) => (child.type === "id" ? child.value : "")) ??
|
|
[];
|
|
expect(targetChildren).toEqual([
|
|
{ type: "id", value: expectString },
|
|
{ type: "id", value: expectString },
|
|
]);
|
|
expect($instances.get().get(pastedPortalId ?? "")?.component).toBe(
|
|
portalComponent
|
|
);
|
|
expect($instances.get().get(pastedBoxId ?? "")?.component).toBe("Box");
|
|
});
|
|
|
|
test("insert into portal fragment when portal is a target", async () => {
|
|
setPageRoot("body");
|
|
$instances.set(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "box" },
|
|
]),
|
|
createInstance("portal", portalComponent, []),
|
|
createInstance("box", "Box", []),
|
|
])
|
|
);
|
|
selectInstance(["box", "body"]);
|
|
const clipboardData = (await instanceText.onCopy?.()) ?? "";
|
|
selectInstance(["portal", "body"]);
|
|
|
|
// fragment not exists
|
|
const prevInstances = $instances.get();
|
|
await instanceText.onPaste?.(clipboardData);
|
|
const newInstances = getMapDifference(prevInstances, $instances.get());
|
|
const boxId = Array.from(newInstances.values()).find(
|
|
(instance) => instance.component === "Box"
|
|
)?.id;
|
|
const fragmentId = Array.from(newInstances.values()).find(
|
|
(instance) => instance.component === "Fragment"
|
|
)?.id;
|
|
if (boxId === undefined || fragmentId === undefined) {
|
|
throw Error("Expected pasted Box and Fragment instances");
|
|
}
|
|
expect($instances.get()).toEqual(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "box" },
|
|
]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: fragmentId },
|
|
]),
|
|
createInstance(fragmentId, "Fragment", [{ type: "id", value: boxId }]),
|
|
createInstance("box", "Box", []),
|
|
createInstance(boxId, "Box", []),
|
|
])
|
|
);
|
|
|
|
// fragment already exists
|
|
selectInstance(["portal", "body"]);
|
|
await instanceText.onPaste?.(clipboardData);
|
|
expect($instances.get()).toEqual(
|
|
toMap([
|
|
createInstance("body", "Body", [
|
|
{ type: "id", value: "portal" },
|
|
{ type: "id", value: "box" },
|
|
]),
|
|
createInstance("portal", portalComponent, [
|
|
{ type: "id", value: fragmentId },
|
|
]),
|
|
createInstance(fragmentId, "Fragment", [
|
|
{ type: "id", value: boxId },
|
|
{ type: "id", value: expectString },
|
|
]),
|
|
createInstance("box", "Box", []),
|
|
createInstance(boxId, "Box", []),
|
|
createInstance(expectString, "Box", []),
|
|
])
|
|
);
|
|
});
|