mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
## Summary Prevent page, template, folder, and marketplace page imports from silently replacing local declarations on the shared global root. When incoming root styles differ at the same property, breakpoint, and state, Builder now asks whether to preserve the project values or use the incoming values. Cancellation happens before the runtime mutation. ## Technical choices - Compare CSS values by their serialized meaning so equivalent keyword and unparsed values do not create false conflicts. - Plan breakpoint remapping with the same insertion path used by fragment paste, including ID collisions and breakpoint limits. - Keep named-token conflict resolution separate because token conflicts support suffixing and merging, while a shared root-local declaration has only an existing or incoming value. - Default the root-style dialog to **Keep existing** because global root styles affect every page, and show the breakpoint plus current and incoming values for each conflict. - Re-read current project data after the asynchronous token dialog before detecting root conflicts. - Leave no-conflict resolution undefined so the runtime performs a final conflict check instead of treating it as an explicit **Use incoming** choice. - Resolve root conflicts at the page-copy boundary before using the generic fragment/style insertion path. - Reuse one root-conflict UI resolver for clipboard paste and marketplace imports, one page-transfer traversal helper in the UI and runtime, and one presentational shell for token and root-style conflict dialogs. - Require an explicit `rootStyleConflictResolution` for conflicting `pageTransfer.insert` and `pages.copy` runtime operations so non-UI callers cannot overwrite global values accidentally. - Document that `ours` keeps target-project values and `theirs` uses incoming values in both generated runtime contracts. ## Todo - [x] Detect conflicting root-local declarations by property, breakpoint, and state - [x] Add keep-existing, use-incoming, and cancel behavior - [x] Cover page, template, nested-folder, marketplace, and direct runtime transfer paths - [x] Preserve non-conflicting incoming root declarations with either resolution - [x] Avoid false conflicts for equivalent CSS values and colliding breakpoint IDs - [x] Revalidate root styles after asynchronous token resolution - [x] Preserve existing named-token conflict behavior - [x] Remove duplicated transfer traversal, conflict resolution mapping, and dialog layout - [x] Regenerate the runtime operation contract - [x] Verify regression tests fail when their corresponding behavior is disabled - [x] Run targeted tests, typechecks, formatting, and lint ## Verification - `pnpm --filter @webstudio-is/project-build test -- src/runtime/style-copy.test.ts src/runtime/fragment.test.tsx src/runtime/page-copy.test.tsx` — 80 passed - `pnpm --filter @webstudio-is/builder test -- app/shared/copy-paste/plugin-page.test.ts app/shared/root-style-conflict-dialog.test.tsx app/shared/token-conflict-dialog.test.tsx` — 17 passed - Retrospective fail/pass audit covered semantic CSS equality, breakpoint ID collisions, both runtime guards, both resolution directions, folder/template paths, conflict detail rendering, and stale target data after token resolution - `pnpm --filter @webstudio-is/project-build typecheck` — passed - `pnpm --filter @webstudio-is/builder typecheck` — passed - `pnpm lint` — passed - Runtime contract regeneration was repeated and produced identical output - Fixture pipeline not run locally: no fixture inputs or outputs are affected; GitHub fixture checks passed on the previous revision - Agent evaluations not run: repository guidance requires separate explicit approval before running them ## Compatibility and limitations The new runtime inputs are optional for non-conflicting imports. Existing imports continue unchanged; only imports that would overwrite a conflicting shared root-local declaration require an explicit resolution. No migrations or persistence changes are required. Closes #5970
816 lines
21 KiB
TypeScript
816 lines
21 KiB
TypeScript
import { afterEach, expect, test, vi } from "vitest";
|
|
import { enableMapSet } from "immer";
|
|
import { createDefaultPages } from "@webstudio-is/project-build";
|
|
import {
|
|
encodeDataSourceVariable,
|
|
ROOT_FOLDER_ID,
|
|
ROOT_INSTANCE_ID,
|
|
type DataSource,
|
|
type Instance,
|
|
} from "@webstudio-is/sdk";
|
|
import type { Project } from "@webstudio-is/project";
|
|
import { registerContainers } from "../sync/sync-stores";
|
|
import {
|
|
$assets,
|
|
$breakpoints,
|
|
$dataSources,
|
|
$instances,
|
|
$pages,
|
|
$project,
|
|
$props,
|
|
$resources,
|
|
$styleSourceSelections,
|
|
$styleSources,
|
|
$styles,
|
|
} from "../sync/data-stores";
|
|
import {
|
|
$editingPageId,
|
|
$editingTemplateId,
|
|
selectInstance,
|
|
$selectedPageId,
|
|
} from "../nano-states";
|
|
import {
|
|
copyFolderData,
|
|
copyPageData,
|
|
copyTemplateData,
|
|
handlePastePage,
|
|
pageText,
|
|
} from "./plugin-page";
|
|
import { pasteHandled, pasteIgnored } from "./copy-paste";
|
|
import { initBuilderApi } from "../builder-api";
|
|
|
|
enableMapSet();
|
|
registerContainers();
|
|
initBuilderApi();
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const resetBuildStores = () => {
|
|
$instances.set(new Map());
|
|
$props.set(new Map());
|
|
$dataSources.set(new Map());
|
|
$resources.set(new Map());
|
|
$breakpoints.set(new Map());
|
|
$styleSourceSelections.set(new Map());
|
|
$styleSources.set(new Map());
|
|
$styles.set(new Map());
|
|
$assets.set(new Map());
|
|
$editingPageId.set(undefined);
|
|
$editingTemplateId.set(undefined);
|
|
selectInstance(undefined);
|
|
$selectedPageId.set(undefined);
|
|
};
|
|
|
|
const setRootLocalStyle = ({
|
|
styleSourceId,
|
|
value,
|
|
}: {
|
|
styleSourceId: string;
|
|
value: string;
|
|
}) => {
|
|
$instances.set(
|
|
new Map([
|
|
...$instances.get(),
|
|
[
|
|
ROOT_INSTANCE_ID,
|
|
{
|
|
type: "instance",
|
|
id: ROOT_INSTANCE_ID,
|
|
component: "Box",
|
|
children: [],
|
|
} satisfies Instance,
|
|
],
|
|
])
|
|
);
|
|
$breakpoints.set(new Map([["base", { id: "base", label: "Base" }]]));
|
|
$styleSources.set(
|
|
new Map([[styleSourceId, { id: styleSourceId, type: "local" }]])
|
|
);
|
|
$styleSourceSelections.set(
|
|
new Map([
|
|
[
|
|
ROOT_INSTANCE_ID,
|
|
{ instanceId: ROOT_INSTANCE_ID, values: [styleSourceId] },
|
|
],
|
|
])
|
|
);
|
|
$styles.set(
|
|
new Map([
|
|
[
|
|
`${styleSourceId}:base:color:`,
|
|
{
|
|
styleSourceId,
|
|
breakpointId: "base",
|
|
property: "color",
|
|
value: { type: "keyword", value },
|
|
},
|
|
],
|
|
])
|
|
);
|
|
};
|
|
|
|
const addRootTokenStyle = ({
|
|
styleSourceId,
|
|
value,
|
|
}: {
|
|
styleSourceId: string;
|
|
value: string;
|
|
}) => {
|
|
const styleSources = new Map($styleSources.get());
|
|
styleSources.set(styleSourceId, {
|
|
id: styleSourceId,
|
|
type: "token",
|
|
name: "primary",
|
|
});
|
|
$styleSources.set(styleSources);
|
|
|
|
const rootSelection = $styleSourceSelections.get().get(ROOT_INSTANCE_ID);
|
|
const selections = new Map($styleSourceSelections.get());
|
|
selections.set(ROOT_INSTANCE_ID, {
|
|
instanceId: ROOT_INSTANCE_ID,
|
|
values: [...(rootSelection?.values ?? []), styleSourceId],
|
|
});
|
|
$styleSourceSelections.set(selections);
|
|
|
|
const styles = new Map($styles.get());
|
|
styles.set(`${styleSourceId}:base:color:`, {
|
|
styleSourceId,
|
|
breakpointId: "base",
|
|
property: "color",
|
|
value: { type: "keyword", value },
|
|
});
|
|
$styles.set(styles);
|
|
};
|
|
|
|
const updateRootLocalStyle = (value: string) => {
|
|
const rootLocalStyle = Array.from($styles.get().values()).find(
|
|
(style) =>
|
|
$styleSources.get().get(style.styleSourceId)?.type === "local" &&
|
|
style.property === "color"
|
|
);
|
|
if (rootLocalStyle === undefined) {
|
|
throw new Error("Expected root local color style");
|
|
}
|
|
const styles = new Map($styles.get());
|
|
styles.set(`${rootLocalStyle.styleSourceId}:base:color:`, {
|
|
...rootLocalStyle,
|
|
value: { type: "keyword", value },
|
|
});
|
|
$styles.set(styles);
|
|
};
|
|
|
|
const setupProjectWithRootStyle = ({
|
|
projectId,
|
|
pageId,
|
|
rootInstanceId,
|
|
styleSourceId,
|
|
color,
|
|
}: {
|
|
projectId: string;
|
|
pageId: string;
|
|
rootInstanceId: string;
|
|
styleSourceId: string;
|
|
color: string;
|
|
}) => {
|
|
$project.set({ id: projectId } as Project);
|
|
resetBuildStores();
|
|
const pages = createDefaultPages({ homePageId: pageId, rootInstanceId });
|
|
$pages.set(pages);
|
|
$instances.set(
|
|
new Map([
|
|
[
|
|
rootInstanceId,
|
|
{
|
|
type: "instance",
|
|
id: rootInstanceId,
|
|
component: "Body",
|
|
children: [],
|
|
} satisfies Instance,
|
|
],
|
|
])
|
|
);
|
|
setRootLocalStyle({ styleSourceId, value: color });
|
|
return pages;
|
|
};
|
|
|
|
test("copies the selected page root from the copy plugin", () => {
|
|
$project.set({ id: "source-project" } as Project);
|
|
resetBuildStores();
|
|
|
|
const pages = createDefaultPages({
|
|
homePageId: "source-page",
|
|
rootInstanceId: "source-root",
|
|
});
|
|
const page = pages.pages.get("source-page");
|
|
if (page === undefined) {
|
|
throw new Error("Expected source page");
|
|
}
|
|
page.name = "Selected Page";
|
|
$pages.set(pages);
|
|
$selectedPageId.set("source-page");
|
|
selectInstance(["source-root"]);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>([
|
|
[
|
|
"source-root",
|
|
{
|
|
type: "instance",
|
|
id: "source-root",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(pageText.onCopy?.()).toContain('"type":"page"');
|
|
expect(pageText.onCopy?.()).toContain('"name":"Selected Page"');
|
|
});
|
|
|
|
test("copies the edited folder from the copy plugin", () => {
|
|
$project.set({ id: "source-project" } as Project);
|
|
resetBuildStores();
|
|
|
|
const pages = createDefaultPages({
|
|
homePageId: "source-page",
|
|
rootInstanceId: "source-root",
|
|
});
|
|
pages.folders.set("source-folder", {
|
|
id: "source-folder",
|
|
name: "Edited Folder",
|
|
slug: "edited-folder",
|
|
children: [],
|
|
});
|
|
$pages.set(pages);
|
|
$editingPageId.set("source-folder");
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>([
|
|
[
|
|
"source-root",
|
|
{
|
|
type: "instance",
|
|
id: "source-root",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
expect(pageText.onCopy?.()).toContain('"type":"folder"');
|
|
expect(pageText.onCopy?.()).toContain('"name":"Edited Folder"');
|
|
});
|
|
|
|
test("copies page data across projects", async () => {
|
|
$project.set({ id: "source-project" } as Project);
|
|
resetBuildStores();
|
|
|
|
const sourcePages = createDefaultPages({
|
|
homePageId: "source-page",
|
|
rootInstanceId: "source-root",
|
|
});
|
|
const variable: DataSource = {
|
|
id: "source-variable",
|
|
scopeInstanceId: "source-root",
|
|
name: "title",
|
|
type: "variable",
|
|
value: { type: "string", value: "" },
|
|
};
|
|
const variableName = encodeDataSourceVariable(variable.id);
|
|
const sourcePage = sourcePages.pages.get("source-page");
|
|
if (sourcePage === undefined) {
|
|
throw new Error("Expected source page");
|
|
}
|
|
sourcePage.name = "Landing";
|
|
sourcePage.path = "/landing";
|
|
sourcePage.title = `"Landing " + ${variableName}`;
|
|
sourcePage.meta.description = `"Description " + ${variableName}`;
|
|
|
|
$pages.set(sourcePages);
|
|
$selectedPageId.set(sourcePages.homePageId);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>([
|
|
[
|
|
"source-root",
|
|
{
|
|
type: "instance",
|
|
id: "source-root",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
$dataSources.set(new Map([[variable.id, variable]]));
|
|
|
|
const clipboardData = copyPageData("source-page");
|
|
expect(clipboardData).toBeDefined();
|
|
|
|
$project.set({ id: "target-project" } as Project);
|
|
resetBuildStores();
|
|
const targetPages = createDefaultPages({
|
|
homePageId: "target-page",
|
|
rootInstanceId: "target-root",
|
|
});
|
|
$pages.set(targetPages);
|
|
$selectedPageId.set(targetPages.homePageId);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>([
|
|
[
|
|
"target-root",
|
|
{
|
|
type: "instance",
|
|
id: "target-root",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
|
|
|
|
const pastedPage = Array.from($pages.get()?.pages.values() ?? []).find(
|
|
(page) => page.name === "Landing"
|
|
);
|
|
expect(pastedPage).toEqual({
|
|
id: expect.any(String),
|
|
name: "Landing",
|
|
path: "/landing",
|
|
title: expect.stringContaining("$ws$dataSource$"),
|
|
rootInstanceId: expect.not.stringMatching("source-root"),
|
|
meta: {
|
|
description: expect.stringContaining("$ws$dataSource$"),
|
|
},
|
|
});
|
|
expect($instances.get().has(pastedPage?.rootInstanceId ?? "")).toBe(true);
|
|
expect($dataSources.get().has("source-variable")).toBe(false);
|
|
});
|
|
|
|
test.each([
|
|
["ours", "blue"],
|
|
["theirs", "red"],
|
|
] as const)(
|
|
"resolves conflicting global root styles with %s",
|
|
async (resolution, expectedColor) => {
|
|
setupProjectWithRootStyle({
|
|
projectId: "source-project",
|
|
pageId: "source-page",
|
|
rootInstanceId: "source-body",
|
|
styleSourceId: "source-local",
|
|
color: "red",
|
|
});
|
|
$styles.get().set("source-local:base:fontSize:", {
|
|
styleSourceId: "source-local",
|
|
breakpointId: "base",
|
|
property: "fontSize",
|
|
value: { type: "keyword", value: "medium" },
|
|
});
|
|
const clipboardData = copyPageData("source-page");
|
|
|
|
setupProjectWithRootStyle({
|
|
projectId: "target-project",
|
|
pageId: "target-page",
|
|
rootInstanceId: "target-body",
|
|
styleSourceId: "target-local",
|
|
color: "blue",
|
|
});
|
|
const conflictDialog = vi
|
|
.spyOn(window.__webstudio__$__builderApi, "showRootStyleConflictDialog")
|
|
.mockResolvedValue(resolution);
|
|
|
|
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
|
|
|
|
expect(conflictDialog).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
incomingStyle: expect.objectContaining({
|
|
property: "color",
|
|
value: { type: "keyword", value: "red" },
|
|
}),
|
|
}),
|
|
]);
|
|
expect($styles.get().get("target-local:base:color:")?.value).toEqual({
|
|
type: "keyword",
|
|
value: expectedColor,
|
|
});
|
|
expect($styles.get().get("target-local:base:fontSize:")?.value).toEqual({
|
|
type: "keyword",
|
|
value: "medium",
|
|
});
|
|
}
|
|
);
|
|
|
|
test("cancels page paste when global root style resolution is cancelled", async () => {
|
|
setupProjectWithRootStyle({
|
|
projectId: "source-project",
|
|
pageId: "source-page",
|
|
rootInstanceId: "source-body",
|
|
styleSourceId: "source-local",
|
|
color: "red",
|
|
});
|
|
const clipboardData = copyPageData("source-page");
|
|
|
|
const targetPages = setupProjectWithRootStyle({
|
|
projectId: "target-project",
|
|
pageId: "target-page",
|
|
rootInstanceId: "target-body",
|
|
styleSourceId: "target-local",
|
|
color: "blue",
|
|
});
|
|
const initialPageCount = targetPages.pages.size;
|
|
vi.spyOn(
|
|
window.__webstudio__$__builderApi,
|
|
"showRootStyleConflictDialog"
|
|
).mockResolvedValue("cancel");
|
|
|
|
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
|
|
|
|
expect($pages.get()?.pages.size).toBe(initialPageCount);
|
|
expect($styles.get().get("target-local:base:color:")?.value).toEqual({
|
|
type: "keyword",
|
|
value: "blue",
|
|
});
|
|
});
|
|
|
|
test("checks current root styles after resolving token conflicts", async () => {
|
|
setupProjectWithRootStyle({
|
|
projectId: "source-project",
|
|
pageId: "source-page",
|
|
rootInstanceId: "source-body",
|
|
styleSourceId: "source-local",
|
|
color: "red",
|
|
});
|
|
addRootTokenStyle({ styleSourceId: "source-token", value: "red" });
|
|
const clipboardData = copyPageData("source-page");
|
|
|
|
setupProjectWithRootStyle({
|
|
projectId: "target-project",
|
|
pageId: "target-page",
|
|
rootInstanceId: "target-body",
|
|
styleSourceId: "target-local",
|
|
color: "red",
|
|
});
|
|
addRootTokenStyle({ styleSourceId: "target-token", value: "blue" });
|
|
vi.spyOn(
|
|
window.__webstudio__$__builderApi,
|
|
"showTokenConflictDialog"
|
|
).mockImplementation(async () => {
|
|
updateRootLocalStyle("blue");
|
|
return "ours";
|
|
});
|
|
const rootConflictDialog = vi
|
|
.spyOn(window.__webstudio__$__builderApi, "showRootStyleConflictDialog")
|
|
.mockResolvedValue("ours");
|
|
|
|
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
|
|
|
|
expect(rootConflictDialog).toHaveBeenCalledOnce();
|
|
expect($styles.get().get("target-local:base:color:")?.value).toEqual({
|
|
type: "keyword",
|
|
value: "blue",
|
|
});
|
|
});
|
|
|
|
test("handles page paste data without falling through when insertion cannot complete", async () => {
|
|
$project.set({ id: "source-project" } as Project);
|
|
resetBuildStores();
|
|
|
|
const sourcePages = createDefaultPages({
|
|
homePageId: "source-page",
|
|
rootInstanceId: "source-root",
|
|
});
|
|
$pages.set(sourcePages);
|
|
$selectedPageId.set(sourcePages.homePageId);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>([
|
|
[
|
|
"source-root",
|
|
{
|
|
type: "instance",
|
|
id: "source-root",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
const clipboardData = copyPageData("source-page");
|
|
expect(clipboardData).toBeDefined();
|
|
|
|
resetBuildStores();
|
|
$project.set(undefined);
|
|
const targetPages = createDefaultPages({
|
|
homePageId: "target-page",
|
|
rootInstanceId: "target-root",
|
|
});
|
|
const initialPageCount = targetPages.pages.size;
|
|
$pages.set(targetPages);
|
|
$selectedPageId.set(targetPages.homePageId);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>([
|
|
[
|
|
"target-root",
|
|
{
|
|
type: "instance",
|
|
id: "target-root",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
await expect(
|
|
handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID)
|
|
).resolves.toEqual(pasteHandled);
|
|
expect($pages.get()?.pages.size).toBe(initialPageCount);
|
|
});
|
|
|
|
test("ignores non-page paste data", async () => {
|
|
await expect(handlePastePage("plain text", ROOT_FOLDER_ID)).resolves.toEqual(
|
|
pasteIgnored
|
|
);
|
|
});
|
|
|
|
test("copies folder data with nested pages across projects", async () => {
|
|
$project.set({ id: "source-project" } as Project);
|
|
resetBuildStores();
|
|
|
|
const sourcePages = createDefaultPages({
|
|
homePageId: "source-home",
|
|
rootInstanceId: "source-home-root",
|
|
});
|
|
sourcePages.folders.set("source-folder", {
|
|
id: "source-folder",
|
|
name: "Docs",
|
|
slug: "docs",
|
|
children: ["source-page-a", "source-subfolder"],
|
|
});
|
|
sourcePages.folders.set("source-subfolder", {
|
|
id: "source-subfolder",
|
|
name: "Nested",
|
|
slug: "nested",
|
|
children: ["source-page-b"],
|
|
});
|
|
sourcePages.folders
|
|
.get(sourcePages.rootFolderId)
|
|
?.children.push("source-folder");
|
|
sourcePages.pages.set("source-page-a", {
|
|
id: "source-page-a",
|
|
name: "Overview",
|
|
path: "/overview",
|
|
title: `"Overview"`,
|
|
rootInstanceId: "source-page-a-root",
|
|
meta: {},
|
|
});
|
|
sourcePages.pages.set("source-page-b", {
|
|
id: "source-page-b",
|
|
name: "Details",
|
|
path: "/details",
|
|
title: `"Details"`,
|
|
rootInstanceId: "source-page-b-root",
|
|
meta: {},
|
|
});
|
|
|
|
$pages.set(sourcePages);
|
|
$selectedPageId.set(sourcePages.homePageId);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>(
|
|
["source-home-root", "source-page-a-root", "source-page-b-root"].map(
|
|
(id) => [
|
|
id,
|
|
{
|
|
type: "instance",
|
|
id,
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
]
|
|
)
|
|
)
|
|
);
|
|
setRootLocalStyle({ styleSourceId: "source-local", value: "red" });
|
|
|
|
const clipboardData = copyFolderData("source-folder");
|
|
expect(clipboardData).toBeDefined();
|
|
|
|
$project.set({ id: "target-project" } as Project);
|
|
resetBuildStores();
|
|
const targetPages = createDefaultPages({
|
|
homePageId: "target-home",
|
|
rootInstanceId: "target-home-root",
|
|
});
|
|
$pages.set(targetPages);
|
|
$selectedPageId.set(targetPages.homePageId);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>([
|
|
[
|
|
"target-home-root",
|
|
{
|
|
type: "instance",
|
|
id: "target-home-root",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
setRootLocalStyle({ styleSourceId: "target-local", value: "blue" });
|
|
const conflictDialog = vi
|
|
.spyOn(window.__webstudio__$__builderApi, "showRootStyleConflictDialog")
|
|
.mockResolvedValue("ours");
|
|
|
|
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
|
|
|
|
expect(conflictDialog).toHaveBeenCalledOnce();
|
|
expect($styles.get().get("target-local:base:color:")?.value).toEqual({
|
|
type: "keyword",
|
|
value: "blue",
|
|
});
|
|
const pastedFolder = Array.from($pages.get()?.folders.values() ?? []).find(
|
|
(folder) => folder.name === "Docs"
|
|
);
|
|
expect(pastedFolder).toBeDefined();
|
|
const pastedSubfolder = Array.from($pages.get()?.folders.values() ?? []).find(
|
|
(folder) => folder.name === "Nested"
|
|
);
|
|
expect(pastedSubfolder).toBeDefined();
|
|
const pastedPages = Array.from($pages.get()?.pages.values() ?? []).filter(
|
|
(page) => page.name === "Overview" || page.name === "Details"
|
|
);
|
|
expect(pastedPages).toHaveLength(2);
|
|
for (const page of pastedPages) {
|
|
expect(page.rootInstanceId).not.toMatch(/^source-/);
|
|
expect($instances.get().has(page.rootInstanceId)).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("preserves empty folder slugs when pasting folder data", async () => {
|
|
$project.set({ id: "source-project" } as Project);
|
|
resetBuildStores();
|
|
|
|
const sourcePages = createDefaultPages({
|
|
homePageId: "source-home",
|
|
rootInstanceId: "source-home-root",
|
|
});
|
|
sourcePages.folders.set("source-folder", {
|
|
id: "source-folder",
|
|
name: "Empty Slug Folder",
|
|
slug: "",
|
|
children: [],
|
|
});
|
|
sourcePages.folders
|
|
.get(sourcePages.rootFolderId)
|
|
?.children.push("source-folder");
|
|
$pages.set(sourcePages);
|
|
$selectedPageId.set(sourcePages.homePageId);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>([
|
|
[
|
|
"source-home-root",
|
|
{
|
|
type: "instance",
|
|
id: "source-home-root",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
const clipboardData = copyFolderData("source-folder");
|
|
expect(clipboardData).toBeDefined();
|
|
|
|
$project.set({ id: "target-project" } as Project);
|
|
resetBuildStores();
|
|
const targetPages = createDefaultPages({
|
|
homePageId: "target-home",
|
|
rootInstanceId: "target-home-root",
|
|
});
|
|
targetPages.folders.set("existing-empty-slug-folder", {
|
|
id: "existing-empty-slug-folder",
|
|
name: "Existing Empty Slug Folder",
|
|
slug: "",
|
|
children: [],
|
|
});
|
|
targetPages.folders
|
|
.get(targetPages.rootFolderId)
|
|
?.children.push("existing-empty-slug-folder");
|
|
$pages.set(targetPages);
|
|
$selectedPageId.set(targetPages.homePageId);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>([
|
|
[
|
|
"target-home-root",
|
|
{
|
|
type: "instance",
|
|
id: "target-home-root",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
|
|
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
|
|
|
|
const pastedFolder = Array.from($pages.get()?.folders.values() ?? []).find(
|
|
(folder) => folder.name === "Empty Slug Folder"
|
|
);
|
|
expect(pastedFolder?.slug).toBe("");
|
|
});
|
|
|
|
test("copies template data as a template", async () => {
|
|
$project.set({ id: "source-project" } as Project);
|
|
resetBuildStores();
|
|
|
|
const sourcePages = createDefaultPages({
|
|
homePageId: "source-home",
|
|
rootInstanceId: "source-home-root",
|
|
});
|
|
sourcePages.pageTemplates = new Map([
|
|
[
|
|
"template-id",
|
|
{
|
|
id: "template-id",
|
|
name: "Landing Template",
|
|
title: `"Landing title"`,
|
|
rootInstanceId: "template-root",
|
|
meta: { description: `"Template description"` },
|
|
},
|
|
],
|
|
]);
|
|
$pages.set(sourcePages);
|
|
$selectedPageId.set(sourcePages.homePageId);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>(
|
|
["source-home-root", "template-root"].map((id) => [
|
|
id,
|
|
{
|
|
type: "instance",
|
|
id,
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
])
|
|
)
|
|
);
|
|
setRootLocalStyle({ styleSourceId: "source-local", value: "red" });
|
|
|
|
const clipboardData = copyTemplateData("template-id");
|
|
expect(clipboardData).toBeDefined();
|
|
|
|
$project.set({ id: "target-project" } as Project);
|
|
resetBuildStores();
|
|
const targetPages = createDefaultPages({
|
|
homePageId: "target-home",
|
|
rootInstanceId: "target-home-root",
|
|
});
|
|
$pages.set(targetPages);
|
|
$selectedPageId.set(targetPages.homePageId);
|
|
$instances.set(
|
|
new Map<Instance["id"], Instance>([
|
|
[
|
|
"target-home-root",
|
|
{
|
|
type: "instance",
|
|
id: "target-home-root",
|
|
component: "Body",
|
|
children: [],
|
|
},
|
|
],
|
|
])
|
|
);
|
|
setRootLocalStyle({ styleSourceId: "target-local", value: "blue" });
|
|
const conflictDialog = vi
|
|
.spyOn(window.__webstudio__$__builderApi, "showRootStyleConflictDialog")
|
|
.mockResolvedValue("theirs");
|
|
|
|
await handlePastePage(clipboardData ?? "", ROOT_FOLDER_ID);
|
|
|
|
expect(conflictDialog).toHaveBeenCalledOnce();
|
|
expect($styles.get().get("target-local:base:color:")?.value).toEqual({
|
|
type: "keyword",
|
|
value: "red",
|
|
});
|
|
const pastedTemplate = Array.from(
|
|
$pages.get()?.pageTemplates?.values() ?? []
|
|
).find((template) => template.name === "Landing Template");
|
|
expect(pastedTemplate).toEqual({
|
|
id: expect.any(String),
|
|
name: "Landing Template",
|
|
title: `"Landing title"`,
|
|
rootInstanceId: expect.any(String),
|
|
meta: { description: `"Template description"` },
|
|
});
|
|
expect(pastedTemplate?.rootInstanceId).not.toBe("template-root");
|
|
|
|
const pastedPage = Array.from($pages.get()?.pages.values() ?? []).find(
|
|
(page) => page.name === "Landing Template"
|
|
);
|
|
expect(pastedPage).toBeUndefined();
|
|
});
|