mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
fix: preserve cloned bindings and Content template design (#6533)
Cloning a Collection can leave its item bindings attached to the original Collection. Content-mode template insertion also drops fixed image attributes, rich-text editing can remove inline images, and YouTube previews retain the previous thumbnail after a URL edit. Reuse the Builder fragment-copy path for cloning, preserve exact fixed props from the same Content Block template, protect mixed image/text content from destructive editing, and refresh video previews. Direct component metadata lookup now includes hidden required parts without adding them to component lists. Unsupported Resource fields now return validation errors instead of unchanged success. Destructive confirmation failures retain their reason through the existing issue-report pipeline, and reports include the negotiated server API contract alongside the CLI contract. Contract versions still use the existing serialized-schema hash algorithm. ## Verification - [x] Reproduce each behavior fix and demonstrate regression tests fail before/pass after. - [x] Verify native Content template insertion, content editing, and reload through isolated local Builder E2E. - [x] Check copy permissions: reject arbitrary design values, reused/mismatched prop keys, changed clone structure, and copies from another Content Block. - [x] Remove the unused clone implementation and reuse the UI's copy/remapping logic. - [x] Review documentation impact; existing instructions remain accurate, no documentation update needed. - [x] Regenerate fixture builds from committed snapshots; generated fixture files remain unchanged. - [x] Complete generated API/docs checks, 2,492 project-build tests, 88 protocol tests, 99 focused CLI tests, package typechecks, lint, and formatting. - [x] Full Luna/low evaluation suite: 6/6 fixtures pass after preparing the Content runtime build. Existing baselines are incompatible with this model override; they were not updated. - [x] Independent Astra review: fixed and re-tested Global Root cloning and protected-prop overwrite findings; final review has no findings. - [x] Required CI passes: all 19 main-workflow jobs, visual regression, submodules, and PR lint. Closes #6531 Closes #6519 Closes #6521 Ref #6523 Ref #6524 Ref #6525 Ref #6526 Ref #6527 Ref #6528 Ref #6529
This commit is contained in:
1 parent
a49a6769bd
commit
2570bcdd69
36 files changed
+1652
-801
No files matched your search
@@ -22,6 +22,7 @@ import { blockComponent } from "@webstudio-is/sdk";
|
||||
import { AssetIndexRevisionError } from "@webstudio-is/content-engine";
|
||||
import { DocumentSourceDiagnosticsError } from "@webstudio-is/content-engine/compiler";
|
||||
import * as assetUploader from "@webstudio-is/asset-uploader/server";
|
||||
import * as issueReportService from "./github-issue-report.server";
|
||||
import { apiRouter, __testing__ } from "./api-router.server";
|
||||
import * as assetQueryPreview from "./asset-query-preview.server";
|
||||
import * as db from "~/shared/db";
|
||||
@@ -79,6 +80,69 @@ const createCaller = (context: AppContext) =>
|
||||
apiRouter.createCaller(context) as ApiRouterCaller & RuntimeApiCaller;
|
||||
|
||||
describe("api router build operation adapters", () => {
|
||||
test("keeps the CLI contract and adds the server contract to issue reports", async () => {
|
||||
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
|
||||
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
|
||||
const publishIssueReport = vi
|
||||
.spyOn(issueReportService, "publishConfiguredIssueReport")
|
||||
.mockResolvedValue({
|
||||
status: "created",
|
||||
issueNumber: 1,
|
||||
issueUrl: "https://example.com/issues/1",
|
||||
});
|
||||
const caller = createCaller({
|
||||
...createContext(true),
|
||||
apiClient: {
|
||||
type: "cli",
|
||||
version: "1.0.0",
|
||||
contractVersion: "public-api:client",
|
||||
},
|
||||
});
|
||||
|
||||
await caller.reports.issue({
|
||||
projectId: "project-1",
|
||||
trigger: "user-requested",
|
||||
category: "tool-failure",
|
||||
deduplicationKey: "contract-report",
|
||||
title: "fix: Preserve CLI contract metadata",
|
||||
agent: {
|
||||
client: "Codex",
|
||||
model: "test-model",
|
||||
reasoningEffort: "medium",
|
||||
},
|
||||
runtime: {
|
||||
cliVersion: "1.0.0",
|
||||
nodeVersion: process.versions.node,
|
||||
os: process.platform,
|
||||
osVersion: "1",
|
||||
architecture: process.arch,
|
||||
executionMode: "mcp",
|
||||
apiContractVersion: "public-api:client",
|
||||
},
|
||||
report: {
|
||||
userStory: "Report an issue.",
|
||||
summary: "The contracts should be distinguishable.",
|
||||
attemptedWorkflow: ["Submit the report."],
|
||||
expectedBehavior: "Both contract versions are included.",
|
||||
actualResult: "The server contract replaced the CLI contract.",
|
||||
recoveryAttempts: ["Inspect the issue report."],
|
||||
userImpact: "The report is less useful.",
|
||||
technicalContext:
|
||||
"The client and server may be on different contracts.",
|
||||
acceptanceCriteria: ["Both versions are reported separately."],
|
||||
},
|
||||
});
|
||||
|
||||
expect(publishIssueReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runtime: expect.objectContaining({
|
||||
apiContractVersion: "public-api:client",
|
||||
serverApiContractVersion: publicApiContractVersion,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("submits only configured marketplace products for review", async () => {
|
||||
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
|
||||
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
|
||||
|
||||
@@ -912,7 +912,7 @@ export const apiRouter = router({
|
||||
...(ctx.apiClient.version === undefined
|
||||
? {}
|
||||
: { cliVersion: ctx.apiClient.version }),
|
||||
apiContractVersion: publicApiContractVersion,
|
||||
serverApiContractVersion: publicApiContractVersion,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ const report: IssueReportInput = {
|
||||
architecture: "arm64",
|
||||
executionMode: "mcp",
|
||||
apiContractVersion: "public-api:client",
|
||||
serverApiContractVersion: "public-api:server",
|
||||
bundleVersion: "bundle:client",
|
||||
recentFailure: {
|
||||
entityIds: [
|
||||
@@ -87,6 +88,7 @@ describe("GitHub issue reports", () => {
|
||||
expect(body).toContain("- Operating system: linux 6 (arm64)");
|
||||
expect(body).toContain("- Execution mode: mcp");
|
||||
expect(body).toContain("- API contract: public-api:client");
|
||||
expect(body).toContain("- Server API contract: public-api:server");
|
||||
expect(body).toContain("- Bundle contract: bundle:client");
|
||||
expect(body).toContain("## Captured failure diagnostics");
|
||||
expect(body).toContain("- Tool: `preview.start`");
|
||||
|
||||
@@ -38,6 +38,9 @@ const formatRuntime = (runtime: IssueReportInput["runtime"]) =>
|
||||
`- Operating system: ${runtime.os} ${runtime.osVersion} (${runtime.architecture})`,
|
||||
`- Execution mode: ${runtime.executionMode}`,
|
||||
`- API contract: ${runtime.apiContractVersion}`,
|
||||
...(runtime.serverApiContractVersion === undefined
|
||||
? []
|
||||
: [`- Server API contract: ${runtime.serverApiContractVersion}`]),
|
||||
`- Bundle contract: ${runtime.bundleVersion ?? "unknown"}`,
|
||||
]),
|
||||
...(runtime?.session === undefined
|
||||
|
||||
@@ -17,6 +17,7 @@ export type SeededContentModeProject = {
|
||||
linkInstanceId: string;
|
||||
imageInstanceId: string;
|
||||
videoInstanceId: string;
|
||||
inlineImageHeadingText: string;
|
||||
assetTemplateImageAssetId: string;
|
||||
assetTemplateImageName: string;
|
||||
assetTemplateName: string;
|
||||
@@ -104,6 +105,7 @@ const nestedTemplateText = "Nested content descendant";
|
||||
const pageTemplateName = "Content Page Template";
|
||||
const pageTemplateText = "Content page template heading";
|
||||
const pageTemplateFontSize = "38px";
|
||||
const inlineImageHeadingText = "Heading with protected image";
|
||||
|
||||
const getBaseBreakpointId = (breakpoints: string) => {
|
||||
const parsedBreakpoints = JSON.parse(breakpoints) as Array<{
|
||||
@@ -143,6 +145,8 @@ const createContentModeBuildData = ({
|
||||
const rootInstanceId = "body";
|
||||
const contentBlockId = "content-block";
|
||||
const contentBlockTemplateId = "content-block-template";
|
||||
const inlineImageHeadingId = "inline-image-heading";
|
||||
const inlineImageId = "inline-image";
|
||||
const assetTemplateId = "asset-template";
|
||||
const assetTemplateImageInstanceId = "asset-template-image";
|
||||
const assetTemplateVideoInstanceId = "asset-template-video";
|
||||
@@ -157,6 +161,9 @@ const createContentModeBuildData = ({
|
||||
const shareLinkEditableInstanceId = "share-link-editable-content";
|
||||
const collaborationTemplateId = "collaboration-template";
|
||||
const styledHeadingTemplateId = "styled-heading-template";
|
||||
const styledHeadingTemplateHeadingId = "styled-heading-template-heading";
|
||||
const styledHeadingTemplateSvgId = "styled-heading-template-svg";
|
||||
const styledHeadingTemplatePathId = "styled-heading-template-path";
|
||||
const styledHeadingTemplateLocalStyleSourceId =
|
||||
"styled-heading-template-local-style-source";
|
||||
const isolatedLocalTemplateId = "isolated-local-template";
|
||||
@@ -201,6 +208,7 @@ const createContentModeBuildData = ({
|
||||
component: "ws:block",
|
||||
children: [
|
||||
{ type: "id", value: listItemInstanceId },
|
||||
{ type: "id", value: inlineImageHeadingId },
|
||||
{ type: "id", value: linkInstanceId },
|
||||
{ type: "id", value: imageInstanceId },
|
||||
{ type: "id", value: videoInstanceId },
|
||||
@@ -291,6 +299,25 @@ const createContentModeBuildData = ({
|
||||
label: "Heading",
|
||||
children: [{ type: "text", value: "Initial content" }],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: inlineImageHeadingId,
|
||||
component: "ws:element",
|
||||
tag: "h2",
|
||||
label: "Heading with protected image",
|
||||
children: [
|
||||
{ type: "text", value: inlineImageHeadingText },
|
||||
{ type: "id", value: inlineImageId },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: inlineImageId,
|
||||
component: "ws:element",
|
||||
tag: "img",
|
||||
label: "Protected heading image",
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: linkInstanceId,
|
||||
@@ -358,10 +385,34 @@ const createContentModeBuildData = ({
|
||||
type: "instance",
|
||||
id: styledHeadingTemplateId,
|
||||
component: "ws:element",
|
||||
tag: "h2",
|
||||
tag: "section",
|
||||
label: styledHeadingTemplateName,
|
||||
children: [
|
||||
{ type: "id", value: styledHeadingTemplateHeadingId },
|
||||
{ type: "id", value: styledHeadingTemplateSvgId },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: styledHeadingTemplateHeadingId,
|
||||
component: "ws:element",
|
||||
tag: "h2",
|
||||
children: [{ type: "text", value: styledHeadingTemplateText }],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: styledHeadingTemplateSvgId,
|
||||
component: "ws:element",
|
||||
tag: "svg",
|
||||
children: [{ type: "id", value: styledHeadingTemplatePathId }],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: styledHeadingTemplatePathId,
|
||||
component: "ws:element",
|
||||
tag: "path",
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
type: "instance",
|
||||
id: isolatedLocalTemplateId,
|
||||
@@ -447,7 +498,9 @@ const createContentModeBuildData = ({
|
||||
{ type: "text", value: "System resource variable configured" },
|
||||
{
|
||||
type: "expression",
|
||||
value: `${encodeDataSourceVariable(dataResourceSystemDataSourceId)}.data.year`,
|
||||
value: `${encodeDataSourceVariable(
|
||||
dataResourceSystemDataSourceId
|
||||
)}.data.year`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -496,6 +549,63 @@ const createContentModeBuildData = ({
|
||||
];
|
||||
|
||||
const props = [
|
||||
{
|
||||
id: "styled-heading-template-svg-width",
|
||||
instanceId: styledHeadingTemplateSvgId,
|
||||
name: "width",
|
||||
type: "number",
|
||||
value: 24,
|
||||
},
|
||||
{
|
||||
id: "styled-heading-template-svg-height",
|
||||
instanceId: styledHeadingTemplateSvgId,
|
||||
name: "height",
|
||||
type: "number",
|
||||
value: 24,
|
||||
},
|
||||
{
|
||||
id: "styled-heading-template-svg-viewbox",
|
||||
instanceId: styledHeadingTemplateSvgId,
|
||||
name: "viewBox",
|
||||
type: "string",
|
||||
value: "0 0 24 24",
|
||||
},
|
||||
{
|
||||
id: "styled-heading-template-path-d",
|
||||
instanceId: styledHeadingTemplatePathId,
|
||||
name: "d",
|
||||
type: "string",
|
||||
value: "M12 2 2 22h20L12 2Z",
|
||||
},
|
||||
{
|
||||
id: "styled-heading-template-path-fill",
|
||||
instanceId: styledHeadingTemplatePathId,
|
||||
name: "fill",
|
||||
type: "string",
|
||||
value: "#123456",
|
||||
},
|
||||
{
|
||||
id: "inline-image-src-prop",
|
||||
instanceId: inlineImageId,
|
||||
name: "src",
|
||||
type: "string",
|
||||
value:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Ccircle cx='8' cy='8' r='8' fill='%23000'/%3E%3C/svg%3E",
|
||||
},
|
||||
{
|
||||
id: "inline-image-alt-prop",
|
||||
instanceId: inlineImageId,
|
||||
name: "alt",
|
||||
type: "string",
|
||||
value: "Protected icon",
|
||||
},
|
||||
{
|
||||
id: "inline-image-width-prop",
|
||||
instanceId: inlineImageId,
|
||||
name: "width",
|
||||
type: "number",
|
||||
value: 16,
|
||||
},
|
||||
{
|
||||
id: "link-href-prop",
|
||||
instanceId: linkInstanceId,
|
||||
@@ -749,7 +859,7 @@ const createContentModeBuildData = ({
|
||||
]),
|
||||
styleSourceSelections: JSON.stringify([
|
||||
{
|
||||
instanceId: styledHeadingTemplateId,
|
||||
instanceId: styledHeadingTemplateHeadingId,
|
||||
values: [styledHeadingTemplateLocalStyleSourceId],
|
||||
},
|
||||
{
|
||||
@@ -972,6 +1082,7 @@ export const prepareExistingContentModeProject = async ({
|
||||
linkInstanceId,
|
||||
imageInstanceId,
|
||||
videoInstanceId,
|
||||
inlineImageHeadingText,
|
||||
assetTemplateImageAssetId: seededAssetTemplateImageAssetId,
|
||||
assetTemplateImageName: seededAssetTemplateImageName,
|
||||
assetTemplateName,
|
||||
|
||||
+68
-1
@@ -1,4 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { deleteContentBlockChildAfterCanvasText } from "../flows/block-outline";
|
||||
import {
|
||||
openProjectBuilder,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
expectContentModeTemplateActionsUnavailable,
|
||||
openPagesPanel,
|
||||
} from "../flows/pages-panel";
|
||||
import { selectNavigatorItem } from "../flows/navigator";
|
||||
import { waitForSyncStatus } from "../flows/sync-status";
|
||||
import { insertTemplateAfterCanvasText } from "../flows/template-insertion";
|
||||
import { createContentModeProject } from "../fixtures/content-mode-suite";
|
||||
@@ -276,6 +277,44 @@ test("Editor can edit text and content props but not design props", async ({
|
||||
});
|
||||
await waitForCanvasText({ page, text: "Initial link" });
|
||||
await waitForSyncStatus({ page, status: "idle" });
|
||||
const canvas = await waitForCanvasFrame({ page });
|
||||
|
||||
await insertTemplateAfterCanvasText({
|
||||
page,
|
||||
anchorText: "Initial content",
|
||||
templateName: fixture.styledHeadingTemplateName,
|
||||
});
|
||||
const styledTemplateHeading = canvas
|
||||
.locator("h2")
|
||||
.filter({ hasText: fixture.styledHeadingTemplateText });
|
||||
await expect(styledTemplateHeading).toHaveCSS(
|
||||
"font-size",
|
||||
fixture.styledHeadingTemplateFontSize
|
||||
);
|
||||
const templateSvg = styledTemplateHeading.locator("xpath=..").locator("svg");
|
||||
await expect(templateSvg).toHaveAttribute("viewBox", "0 0 24 24");
|
||||
await expect(templateSvg).toHaveAttribute("width", "24");
|
||||
await expect(templateSvg.locator("path")).toHaveAttribute(
|
||||
"d",
|
||||
"M12 2 2 22h20L12 2Z"
|
||||
);
|
||||
await expect(templateSvg.locator("path")).toHaveAttribute("fill", "#123456");
|
||||
|
||||
const protectedHeading = canvas
|
||||
.locator("h2")
|
||||
.filter({ hasText: fixture.inlineImageHeadingText });
|
||||
const protectedImage = protectedHeading.getByRole("img", {
|
||||
name: "Protected icon",
|
||||
});
|
||||
await expect(protectedImage).toHaveAttribute("width", "16");
|
||||
await selectNavigatorItem({
|
||||
page,
|
||||
name: "Heading with protected image",
|
||||
});
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(canvas.locator("[contenteditable]")).toHaveCount(0);
|
||||
await waitForSyncStatus({ page, status: "idle" });
|
||||
await expect(protectedImage).toBeVisible();
|
||||
|
||||
await insertTemplateAfterCanvasText({
|
||||
page,
|
||||
@@ -300,6 +339,9 @@ test("Editor can edit text and content props but not design props", async ({
|
||||
page,
|
||||
alt: fixture.contentPropsTemplateImageAlt,
|
||||
});
|
||||
await expect(
|
||||
canvas.getByRole("img", { name: fixture.contentPropsTemplateImageAlt })
|
||||
).toHaveAttribute("width", "120");
|
||||
await waitForCanvasVideoSource({
|
||||
page,
|
||||
sourceName: fixture.contentPropsTemplateVideoName,
|
||||
@@ -387,6 +429,28 @@ test("Editor can edit text and content props but not design props", async ({
|
||||
});
|
||||
await waitForCanvasText({ page, text: editedContent });
|
||||
await waitForSyncStatus({ page, status: "idle" });
|
||||
const reloadedCanvas = await waitForCanvasFrame({ page });
|
||||
const reloadedHeading = reloadedCanvas
|
||||
.locator("h2")
|
||||
.filter({ hasText: fixture.inlineImageHeadingText });
|
||||
await expect(
|
||||
reloadedHeading.getByRole("img", { name: "Protected icon" })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
reloadedCanvas
|
||||
.locator("h2")
|
||||
.filter({ hasText: fixture.styledHeadingTemplateText })
|
||||
).toHaveCSS("font-size", fixture.styledHeadingTemplateFontSize);
|
||||
const reloadedTemplateSvg = reloadedCanvas
|
||||
.locator("h2")
|
||||
.filter({ hasText: fixture.styledHeadingTemplateText })
|
||||
.locator("xpath=..")
|
||||
.locator("svg");
|
||||
await expect(reloadedTemplateSvg).toHaveAttribute("viewBox", "0 0 24 24");
|
||||
await expect(reloadedTemplateSvg.locator("path")).toHaveAttribute(
|
||||
"fill",
|
||||
"#123456"
|
||||
);
|
||||
|
||||
await selectCanvasTextInstanceForProps({
|
||||
page,
|
||||
@@ -417,6 +481,9 @@ test("Editor can edit text and content props but not design props", async ({
|
||||
value: editedImageAlt,
|
||||
});
|
||||
await expectTextHidden({ page, text: "Width" });
|
||||
await expect(
|
||||
reloadedCanvas.getByRole("img", { name: editedImageAlt })
|
||||
).toHaveAttribute("width", "120");
|
||||
|
||||
await selectCanvasVideoSource({
|
||||
page,
|
||||
|
||||
@@ -306,6 +306,87 @@ test("persists sanitized issue-report failure diagnostics for the next CLI proce
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves a destructive confirmation failure reason without the token or plan", async () => {
|
||||
const projectRoot = await mkdtemp(
|
||||
path.join(tmpdir(), "webstudio-mcp-confirmation-report-")
|
||||
);
|
||||
tempDirs.push(projectRoot);
|
||||
const filePath = getIssueReportFailureFile(projectRoot, "project-1");
|
||||
const operation = publicApiOperations.find(
|
||||
({ command }) => command === "delete-resource"
|
||||
);
|
||||
if (operation === undefined) {
|
||||
throw new Error("Expected delete-resource operation");
|
||||
}
|
||||
const core = createProjectSessionMcpCore({
|
||||
operations: [operation],
|
||||
createProjectSession: () => {
|
||||
throw new Error("Unexpected session access");
|
||||
},
|
||||
executeOperation: async () => ({
|
||||
operationId: "resources.delete",
|
||||
projectId: "project-1",
|
||||
buildId: "build-1",
|
||||
version: 1,
|
||||
source: "dry-run",
|
||||
result: { resourceId: "resource-1" },
|
||||
state: { committed: false, freshness: {} },
|
||||
namespaces: { read: [], write: [], invalidated: [], missing: [] },
|
||||
diagnostics: [],
|
||||
transaction: {
|
||||
id: "plan-1",
|
||||
payload: [
|
||||
{
|
||||
namespace: "resources",
|
||||
patches: [{ op: "remove", path: ["resource-1"] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
const planned = await core.callTool({
|
||||
name: "delete-resource",
|
||||
input: { resourceId: "resource-1", dryRun: true },
|
||||
});
|
||||
const token = planned.structuredContent.meta.confirmation?.token;
|
||||
if (token === undefined) {
|
||||
throw new Error("Expected confirmation token");
|
||||
}
|
||||
const input = {
|
||||
resourceId: "resource-2",
|
||||
confirmDestructive: true,
|
||||
confirmationToken: token,
|
||||
};
|
||||
const rejected = await core.callTool({ name: "delete-resource", input });
|
||||
const firstProcess = createIssueReportFailureTracker();
|
||||
firstProcess.record(
|
||||
"delete-resource",
|
||||
getMcpToolCallError(rejected),
|
||||
100,
|
||||
input
|
||||
);
|
||||
await persistIssueReportFailure(filePath, firstProcess.snapshot());
|
||||
|
||||
const secondProcess = createIssueReportFailureTracker();
|
||||
await restoreIssueReportFailure(filePath, secondProcess);
|
||||
expect(secondProcess.get()).toMatchObject({
|
||||
tool: "delete-resource",
|
||||
code: "DESTRUCTIVE_CONFIRMATION_INVALID",
|
||||
entityIds: [{ field: "resourceId", id: "resource-2" }],
|
||||
issues: [
|
||||
{
|
||||
path: ["confirmationToken"],
|
||||
code: "DESTRUCTIVE_CONFIRMATION_MISMATCH",
|
||||
constraint: "unchanged_destructive_plan",
|
||||
},
|
||||
],
|
||||
});
|
||||
const persisted = await readFile(filePath, "utf8");
|
||||
expect(persisted).not.toContain(token);
|
||||
expect(persisted).not.toContain("resource-1");
|
||||
expect(persisted).not.toContain("plan-1");
|
||||
});
|
||||
|
||||
const getArraySchemasWithoutItems = (schema: unknown): unknown[] => {
|
||||
if (schema === null || typeof schema !== "object") {
|
||||
return [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
createConfirmationToken,
|
||||
getConfirmationTokenInvalidReason,
|
||||
validateConfirmationToken,
|
||||
} from "./confirmation-token";
|
||||
|
||||
@@ -30,4 +31,32 @@ describe("confirmation tokens", () => {
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("reports why a confirmation token is invalid", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-07-16T12:00:00Z"));
|
||||
const { token } = await createConfirmationToken("original", 1_000);
|
||||
|
||||
expect(
|
||||
await getConfirmationTokenInvalidReason(token, "original")
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await getConfirmationTokenInvalidReason(undefined, "original")
|
||||
).toBe("missing");
|
||||
expect(
|
||||
await getConfirmationTokenInvalidReason("invalid", "original")
|
||||
).toBe("malformed");
|
||||
expect(await getConfirmationTokenInvalidReason(token, "changed")).toBe(
|
||||
"mismatch"
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(1_001);
|
||||
expect(await getConfirmationTokenInvalidReason(token, "original")).toBe(
|
||||
"expired"
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -33,12 +33,12 @@ export const createConfirmationToken = async (
|
||||
return { token: `${expiresAt.toString(36)}.${digest}`, expiresAt };
|
||||
};
|
||||
|
||||
export const validateConfirmationToken = async (
|
||||
export const getConfirmationTokenInvalidReason = async (
|
||||
token: string | undefined,
|
||||
payload: unknown
|
||||
) => {
|
||||
if (token === undefined) {
|
||||
return false;
|
||||
return "missing";
|
||||
}
|
||||
const [encodedExpiresAt, digest, ...extra] = token.split(".");
|
||||
if (
|
||||
@@ -46,11 +46,27 @@ export const validateConfirmationToken = async (
|
||||
digest === undefined ||
|
||||
extra.length > 0
|
||||
) {
|
||||
return false;
|
||||
return "malformed";
|
||||
}
|
||||
const expiresAt = Number.parseInt(encodedExpiresAt, 36);
|
||||
if (Number.isSafeInteger(expiresAt) === false || expiresAt < Date.now()) {
|
||||
return false;
|
||||
if (Number.isSafeInteger(expiresAt) === false) {
|
||||
return "malformed";
|
||||
}
|
||||
const hasCanonicalExpiry =
|
||||
expiresAt.toString(36) === encodedExpiresAt.toLowerCase();
|
||||
if (expiresAt < Date.now()) {
|
||||
return hasCanonicalExpiry ? "expired" : "malformed";
|
||||
}
|
||||
if (digest !== (await getConfirmationDigest(payload, expiresAt))) {
|
||||
return hasCanonicalExpiry ? "mismatch" : "malformed";
|
||||
}
|
||||
return digest === (await getConfirmationDigest(payload, expiresAt));
|
||||
};
|
||||
|
||||
export const validateConfirmationToken = async (
|
||||
token: string | undefined,
|
||||
payload: unknown
|
||||
) => {
|
||||
return (
|
||||
(await getConfirmationTokenInvalidReason(token, payload)) === undefined
|
||||
);
|
||||
};
|
||||
+15
-3
@@ -41692,6 +41692,7 @@ export const runtimeOperationContractData = [
|
||||
},
|
||||
},
|
||||
required: ["name", "method", "url", "headers"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
dataSourceName: {
|
||||
type: "string",
|
||||
@@ -64406,9 +64407,11 @@ export const runtimeOperationContractData = [
|
||||
"props",
|
||||
"dataSources",
|
||||
"resources",
|
||||
"styles",
|
||||
"styleSources",
|
||||
"styleSourceSelections",
|
||||
"styles",
|
||||
"breakpoints",
|
||||
"assets",
|
||||
],
|
||||
writeNamespaces: [
|
||||
"pages",
|
||||
@@ -64416,9 +64419,11 @@ export const runtimeOperationContractData = [
|
||||
"props",
|
||||
"dataSources",
|
||||
"resources",
|
||||
"styles",
|
||||
"styleSources",
|
||||
"styleSourceSelections",
|
||||
"styles",
|
||||
"breakpoints",
|
||||
"assets",
|
||||
],
|
||||
invalidatesNamespaces: [
|
||||
"pages",
|
||||
@@ -64426,11 +64431,14 @@ export const runtimeOperationContractData = [
|
||||
"props",
|
||||
"dataSources",
|
||||
"resources",
|
||||
"styles",
|
||||
"styleSources",
|
||||
"styleSourceSelections",
|
||||
"styles",
|
||||
"breakpoints",
|
||||
"assets",
|
||||
],
|
||||
retryOnConflict: false,
|
||||
requiresAssets: true,
|
||||
},
|
||||
{
|
||||
id: "instances.duplicateAfterItself",
|
||||
@@ -75569,6 +75577,7 @@ export const runtimeOperationContractData = [
|
||||
},
|
||||
},
|
||||
required: ["name", "method", "url", "headers"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
scopeInstanceId: {
|
||||
description: 'Instance ID or ":root" for Global Root.',
|
||||
@@ -75825,6 +75834,7 @@ export const runtimeOperationContractData = [
|
||||
'One dynamic Webstudio JavaScript expression, or { type: "literal", value: string } for fixed text. Read webstudio://project/expressions for syntax, scope, resource-result shape, and supported methods.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
required: [],
|
||||
},
|
||||
dataSourceName: {
|
||||
@@ -76184,6 +76194,7 @@ export const runtimeOperationContractData = [
|
||||
},
|
||||
},
|
||||
required: ["name", "method", "url", "headers"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
dataSourceId: {
|
||||
type: "string",
|
||||
@@ -76387,6 +76398,7 @@ export const runtimeOperationContractData = [
|
||||
},
|
||||
},
|
||||
required: ["name", "method", "url", "headers"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
scopeInstanceId: {
|
||||
type: "string",
|
||||
|
||||
@@ -2391,7 +2391,16 @@ describe("project session mcp adapter", () => {
|
||||
expect(altered).toMatchObject({
|
||||
structuredContent: {
|
||||
ok: false,
|
||||
error: { code: "DESTRUCTIVE_CONFIRMATION_INVALID" },
|
||||
error: {
|
||||
code: "DESTRUCTIVE_CONFIRMATION_INVALID",
|
||||
issues: [
|
||||
{
|
||||
code: "DESTRUCTIVE_CONFIRMATION_MISMATCH",
|
||||
path: ["confirmationToken"],
|
||||
constraint: "unchanged_destructive_plan",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(executeOperation).toHaveBeenCalledTimes(2);
|
||||
@@ -5203,6 +5212,40 @@ describe("project session mcp adapter", () => {
|
||||
expect(session.initialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("gets metadata for a hidden required template child", async () => {
|
||||
const session = createSession({
|
||||
initialize: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
});
|
||||
const adapter = createProjectSessionMcpCore({
|
||||
operations: publicMcpOperations,
|
||||
createProjectSession: createSessionFactory(session),
|
||||
executeOperation: createExecuteOperation(),
|
||||
});
|
||||
|
||||
const details = await adapter.callTool({
|
||||
name: "components.get",
|
||||
input: { component: "VimeoPreviewImage" },
|
||||
});
|
||||
const listing = await adapter.callTool({
|
||||
name: "components.list",
|
||||
input: { source: "all" },
|
||||
});
|
||||
|
||||
expect(details.structuredContent.data).toEqual(
|
||||
expect.objectContaining({
|
||||
found: true,
|
||||
component: "VimeoPreviewImage",
|
||||
standaloneInsertable: false,
|
||||
contentModel: expect.objectContaining({ category: "none" }),
|
||||
})
|
||||
);
|
||||
expect(JSON.stringify(listing.structuredContent.data)).not.toContain(
|
||||
"VimeoPreviewImage"
|
||||
);
|
||||
});
|
||||
|
||||
test("exposes structured component discovery without full resource parsing", async () => {
|
||||
const session = createSession({
|
||||
initialize: vi.fn(),
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createConfirmationToken,
|
||||
getConfirmationTokenInvalidReason,
|
||||
validateConfirmationToken,
|
||||
} from "./confirmation-token";
|
||||
|
||||
@@ -5299,9 +5300,11 @@ const getComponentCatalogOverview = () => {
|
||||
const getComponentSummaryEntry = ({
|
||||
component,
|
||||
templates,
|
||||
includeHiddenRequiredPart = false,
|
||||
}: {
|
||||
component: string;
|
||||
templates: ComponentTemplateRegistry;
|
||||
includeHiddenRequiredPart?: boolean;
|
||||
}) => {
|
||||
const meta = componentMetas.get(component);
|
||||
if (meta === undefined) {
|
||||
@@ -5309,9 +5312,20 @@ const getComponentSummaryEntry = ({
|
||||
}
|
||||
const templateMeta = templates.get(component);
|
||||
const visibleCategory = templateMeta?.category ?? meta.category;
|
||||
const hiddenRequiredPart =
|
||||
includeHiddenRequiredPart &&
|
||||
meta.deprecated !== true &&
|
||||
meta.category === "hidden" &&
|
||||
meta.contentModel?.category === "none" &&
|
||||
Array.from(templates.values()).some((entry) =>
|
||||
entry.template.instances.some(
|
||||
(instance) => instance.component === component
|
||||
)
|
||||
);
|
||||
if (
|
||||
isComponentMetaUnavailableInCatalog(meta) ||
|
||||
isComponentHiddenFromCatalog(meta, visibleCategory)
|
||||
hiddenRequiredPart === false &&
|
||||
(isComponentMetaUnavailableInCatalog(meta) ||
|
||||
isComponentHiddenFromCatalog(meta, visibleCategory))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -6127,7 +6141,11 @@ const findComponents = async (
|
||||
|
||||
const getComponentDetails = (component: string) => {
|
||||
const templates = getComponentTemplates();
|
||||
const entry = getComponentSummaryEntry({ component, templates });
|
||||
const entry = getComponentSummaryEntry({
|
||||
component,
|
||||
templates,
|
||||
includeHiddenRequiredPart: true,
|
||||
});
|
||||
const meta = componentMetas.get(component);
|
||||
const stateUsage = getComponentStateUsage(meta?.states);
|
||||
const animationUsage = getAnimationComponentGuidance(component);
|
||||
@@ -7645,7 +7663,7 @@ const toCallResult = (
|
||||
envelope: Parameters<typeof serializeProjectSessionMeta>[0],
|
||||
options: {
|
||||
verboseSession?: boolean;
|
||||
error?: { code: string; message: string };
|
||||
error?: McpStructuredError;
|
||||
next?: string[];
|
||||
confirmation?: DestructiveConfirmation;
|
||||
} = {}
|
||||
@@ -7739,10 +7757,16 @@ const executeDestructiveMcpOperation = async <Command extends string>({
|
||||
}),
|
||||
];
|
||||
}
|
||||
const isConfirmed =
|
||||
confirmDestructive &&
|
||||
(await validateConfirmationToken(confirmationToken, confirmationPayload));
|
||||
const invalidReason = confirmDestructive
|
||||
? await getConfirmationTokenInvalidReason(
|
||||
confirmationToken,
|
||||
confirmationPayload
|
||||
)
|
||||
: undefined;
|
||||
const isConfirmed = confirmDestructive && invalidReason === undefined;
|
||||
if (isConfirmed === false) {
|
||||
const message =
|
||||
"Review the planned destructive mutation, then retry the unchanged call with confirmDestructive: true and the returned confirmationToken before it expires.";
|
||||
return [
|
||||
plannedEnvelope,
|
||||
toCallResult(plannedEnvelope, {
|
||||
@@ -7750,8 +7774,19 @@ const executeDestructiveMcpOperation = async <Command extends string>({
|
||||
code: confirmDestructive
|
||||
? "DESTRUCTIVE_CONFIRMATION_INVALID"
|
||||
: "DESTRUCTIVE_CONFIRMATION_REQUIRED",
|
||||
message:
|
||||
"Review the planned destructive mutation, then retry the unchanged call with confirmDestructive: true and the returned confirmationToken before it expires.",
|
||||
message,
|
||||
...(invalidReason === undefined
|
||||
? {}
|
||||
: {
|
||||
issues: [
|
||||
{
|
||||
code: `DESTRUCTIVE_CONFIRMATION_${invalidReason.toUpperCase()}`,
|
||||
path: ["confirmationToken"],
|
||||
message,
|
||||
constraint: "unchanged_destructive_plan",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
confirmation: await confirmation(),
|
||||
next: [
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { encodeDataVariableId } from "@webstudio-is/sdk";
|
||||
import { encodeDataVariableId, getStyleDeclKey } from "@webstudio-is/sdk";
|
||||
import type { BuilderState } from "../state/builder-state";
|
||||
import { executeBuilderRuntimeOperation } from "./registry";
|
||||
import { verifyBindings } from "./binding-verification";
|
||||
import { context, expectRuntimeValidationError } from "./runtime.test-fixtures";
|
||||
import { applyBuilderPatchPayloadMutable } from "../state/patch";
|
||||
import { cloneInstance } from "./instance-duplicate";
|
||||
|
||||
const createState = (): BuilderState => {
|
||||
const global = encodeDataVariableId("global-title");
|
||||
@@ -211,6 +213,172 @@ describe("project.verifyBindings", () => {
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
|
||||
test("clones Collection parameters and binds each page to its own iterable", async () => {
|
||||
const state = createState();
|
||||
state.assets = new Map();
|
||||
state.assets.set("article-image", {
|
||||
id: "article-image",
|
||||
projectId: "project-id",
|
||||
name: "article.png",
|
||||
type: "image",
|
||||
size: 1,
|
||||
format: "png",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
description: null,
|
||||
meta: { width: 100, height: 100 },
|
||||
});
|
||||
state.breakpoints = new Map([["base", { id: "base", label: "Base" }]]);
|
||||
state.styleSources = new Map();
|
||||
state.styleSourceSelections = new Map();
|
||||
state.styles = new Map();
|
||||
state.styleSources.set("article-token", {
|
||||
id: "article-token",
|
||||
type: "token",
|
||||
name: "Article card",
|
||||
});
|
||||
state.styleSources.set("article-local", {
|
||||
id: "article-local",
|
||||
type: "local",
|
||||
});
|
||||
state.styleSourceSelections.set("collection", {
|
||||
instanceId: "collection",
|
||||
values: ["article-token", "article-local"],
|
||||
});
|
||||
const localStyle = {
|
||||
styleSourceId: "article-local",
|
||||
breakpointId: "base",
|
||||
property: "color" as const,
|
||||
value: { type: "keyword" as const, value: "red" },
|
||||
};
|
||||
state.styles.set(getStyleDeclKey(localStyle), localStyle);
|
||||
const sourceItems = encodeDataVariableId("source-items");
|
||||
const targetItems = encodeDataVariableId("target-items");
|
||||
state.dataSources?.set("source-items", {
|
||||
id: "source-items",
|
||||
type: "variable",
|
||||
name: "articles",
|
||||
scopeInstanceId: "body",
|
||||
value: { type: "json", value: [{ name: "Source article" }] },
|
||||
});
|
||||
state.dataSources?.set("target-items", {
|
||||
id: "target-items",
|
||||
type: "variable",
|
||||
name: "articles",
|
||||
scopeInstanceId: "other-body",
|
||||
value: { type: "json", value: [{ name: "Target article" }] },
|
||||
});
|
||||
state.dataSources?.set("target-title", {
|
||||
id: "target-title",
|
||||
type: "variable",
|
||||
name: "globalTitle",
|
||||
scopeInstanceId: "other-body",
|
||||
value: { type: "string", value: "Other catalog" },
|
||||
});
|
||||
state.props?.set("collection-data", {
|
||||
id: "collection-data",
|
||||
instanceId: "collection",
|
||||
name: "data",
|
||||
type: "expression",
|
||||
value: sourceItems,
|
||||
});
|
||||
state.props?.set("collection-label", {
|
||||
id: "collection-label",
|
||||
instanceId: "collection",
|
||||
name: "aria-label",
|
||||
type: "string",
|
||||
value: "Article list",
|
||||
});
|
||||
state.props?.set("item-image", {
|
||||
id: "item-image",
|
||||
instanceId: "item",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "article-image",
|
||||
});
|
||||
|
||||
let nextId = 0;
|
||||
const mutation = await executeBuilderRuntimeOperation<
|
||||
ReturnType<typeof cloneInstance>
|
||||
>({
|
||||
id: "instances.clone",
|
||||
state,
|
||||
input: {
|
||||
sourceInstanceId: "collection",
|
||||
targetParentInstanceId: "other-body",
|
||||
},
|
||||
context: { ...context, createId: () => `clone-${nextId++}` },
|
||||
});
|
||||
applyBuilderPatchPayloadMutable((namespace) => {
|
||||
const value = state[namespace];
|
||||
if (value instanceof Map) {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Unexpected namespace: ${namespace}`);
|
||||
}, mutation.payload);
|
||||
|
||||
const clonedCollectionId = mutation.result.instanceId;
|
||||
const clonedItemId = state.instances?.get(clonedCollectionId)?.children[0];
|
||||
if (clonedItemId?.type !== "id") {
|
||||
throw new Error("Expected cloned Collection item");
|
||||
}
|
||||
const clonedParameter = [...(state.dataSources?.values() ?? [])].find(
|
||||
(dataSource) =>
|
||||
dataSource.type === "parameter" &&
|
||||
dataSource.scopeInstanceId === clonedCollectionId &&
|
||||
dataSource.name === "collectionItem"
|
||||
);
|
||||
expect(clonedParameter?.id).toBeDefined();
|
||||
expect(clonedParameter?.id).not.toBe("collection-item");
|
||||
const copiedStyles = state.styleSourceSelections?.get(clonedCollectionId);
|
||||
expect(copiedStyles?.values[0]).toBe("article-token");
|
||||
const copiedLocalId = copiedStyles?.values[1];
|
||||
expect(copiedLocalId).toBeDefined();
|
||||
expect(copiedLocalId).not.toBe("article-local");
|
||||
expect(state.styleSources?.get(copiedLocalId ?? "")?.type).toBe("local");
|
||||
expect(
|
||||
state.styles?.get(
|
||||
getStyleDeclKey({ ...localStyle, styleSourceId: copiedLocalId ?? "" })
|
||||
)
|
||||
).toMatchObject({ value: localStyle.value });
|
||||
expect(state.dataSources?.get("source-items")).toMatchObject({
|
||||
value: { value: [{ name: "Source article" }] },
|
||||
});
|
||||
expect(state.dataSources?.get("target-items")).toMatchObject({
|
||||
value: { value: [{ name: "Target article" }] },
|
||||
});
|
||||
expect(
|
||||
[...(state.props?.values() ?? [])].find(
|
||||
(prop) => prop.instanceId === clonedCollectionId && prop.name === "data"
|
||||
)
|
||||
).toMatchObject({ value: targetItems });
|
||||
expect(state.props?.get("collection-data")?.value).toBe(sourceItems);
|
||||
expect(
|
||||
[...(state.props?.values() ?? [])].find(
|
||||
(prop) =>
|
||||
prop.instanceId === clonedCollectionId && prop.name === "aria-label"
|
||||
)
|
||||
).toMatchObject({ value: "Article list" });
|
||||
expect(state.instances?.get("item")?.children[0]).toMatchObject({
|
||||
value: `${encodeDataVariableId("collection-item")}.name`,
|
||||
});
|
||||
expect(
|
||||
[...(state.props?.values() ?? [])].find(
|
||||
(prop) =>
|
||||
prop.instanceId === clonedItemId.value && prop.type === "parameter"
|
||||
)
|
||||
).toMatchObject({ value: clonedParameter?.id });
|
||||
expect(
|
||||
[...(state.props?.values() ?? [])].find(
|
||||
(prop) => prop.instanceId === clonedItemId.value && prop.name === "src"
|
||||
)
|
||||
).toMatchObject({ type: "asset", value: "article-image" });
|
||||
expect(state.instances?.get(clonedItemId.value)?.children[0]).toMatchObject(
|
||||
{ value: `${encodeDataVariableId(clonedParameter?.id ?? "")}?.name` }
|
||||
);
|
||||
expect(verifyBindings(state, { pageId: "other" }).findings).toEqual([]);
|
||||
expect(verifyBindings(state, { pageId: "home" }).findings).toEqual([]);
|
||||
});
|
||||
|
||||
test("reports missing references, scope violations, unknown names, and stale ids", () => {
|
||||
const state = createState();
|
||||
const stale = encodeDataVariableId("deleted-variable");
|
||||
|
||||
@@ -50,10 +50,12 @@ import {
|
||||
} from "./collection";
|
||||
import type { BuilderRuntimeContext } from "./context";
|
||||
import { isFragmentContentModeCopyableProp } from "./content-mode-copy-policy";
|
||||
import { isContentModeTemplateDefaultPropType } from "./content-mode-permissions";
|
||||
import { findAvailableVariables } from "./data";
|
||||
import { addZodValidationIssue, throwBuilderRuntimeError } from "./errors";
|
||||
import {
|
||||
detectFragmentTokenConflicts,
|
||||
extractWebstudioFragment,
|
||||
insertWebstudioFragmentCopy,
|
||||
webstudioFragmentMutationInput,
|
||||
} from "./fragment";
|
||||
@@ -269,6 +271,23 @@ export const getRequiredComponentInsertData = (
|
||||
};
|
||||
};
|
||||
|
||||
const isBlockTemplateFragment = ({
|
||||
data,
|
||||
fragment,
|
||||
}: {
|
||||
data: Omit<WebstudioData, "pages">;
|
||||
fragment: WebstudioFragment;
|
||||
}) =>
|
||||
Array.from(data.instances.values()).some(
|
||||
(instance) =>
|
||||
instance.component === blockTemplateComponent &&
|
||||
instance.children.some(
|
||||
(child) =>
|
||||
child.type === "id" &&
|
||||
equal(extractWebstudioFragment(data, child.value), fragment)
|
||||
)
|
||||
);
|
||||
|
||||
const cloneFragmentData = (
|
||||
state: ReturnType<typeof getRequiredComponentInsertState>
|
||||
): Omit<WebstudioData, "pages"> => {
|
||||
@@ -774,6 +793,12 @@ const createInsertFragmentMutation = <
|
||||
}
|
||||
|
||||
const nextData = cloneFragmentData(mutationState);
|
||||
const copiesContentBlockTemplate =
|
||||
contentMode &&
|
||||
isBlockTemplateFragment({
|
||||
data: mutationState,
|
||||
fragment,
|
||||
});
|
||||
const normalizedSlotDropTarget = getSlotFragmentDropTargetMutable(
|
||||
nextData.instances,
|
||||
{ parentSelector: [originalParent.id], position: "end" },
|
||||
@@ -830,7 +855,10 @@ const createInsertFragmentMutation = <
|
||||
conflictResolution,
|
||||
createId: context.createId,
|
||||
metas: componentMetas,
|
||||
contentModeCopyableProp: isFragmentContentModeCopyableProp,
|
||||
contentModeCopyableProp: (input) =>
|
||||
isFragmentContentModeCopyableProp(input) ||
|
||||
(copiesContentBlockTemplate &&
|
||||
isContentModeTemplateDefaultPropType(input.prop.type)),
|
||||
contentMode,
|
||||
});
|
||||
for (const instanceId of newInstanceIds.values()) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
getContentModePropNamesByTag,
|
||||
isContentModeCopyableProp,
|
||||
isContentModePagePath,
|
||||
isContentModeTemplateDefaultPropType,
|
||||
validateContentModeTransaction,
|
||||
type ContentModePatchTransaction,
|
||||
} from "./content-mode-permissions";
|
||||
@@ -440,6 +441,27 @@ const transaction = (
|
||||
});
|
||||
|
||||
describe("content mode permissions", () => {
|
||||
test("only retains self-contained template default prop types", () => {
|
||||
expect(
|
||||
(["string", "number", "boolean", "json", "string[]"] as const).every(
|
||||
isContentModeTemplateDefaultPropType
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
[
|
||||
"asset",
|
||||
"page",
|
||||
"parameter",
|
||||
"resource",
|
||||
"expression",
|
||||
"action",
|
||||
"animationAction",
|
||||
] as const
|
||||
).some(isContentModeTemplateDefaultPropType)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("allows only static page paths in content mode", () => {
|
||||
expect(isContentModePagePath("/about")).toBe(true);
|
||||
expect(isContentModePagePath("")).toBe(true);
|
||||
@@ -832,6 +854,7 @@ describe("content mode permissions", () => {
|
||||
selectedLocalStyleSourceIds: new Set<string>(),
|
||||
styledLocalStyleSourceIds: new Set<string>(),
|
||||
editableInstanceIds: new Set<string>(),
|
||||
copiedTemplateProps: new Map(),
|
||||
};
|
||||
|
||||
expect(hasOnlyAddedInstancesInSubtree(context, "new-root")).toBe(true);
|
||||
@@ -889,6 +912,7 @@ describe("content mode permissions", () => {
|
||||
selectedLocalStyleSourceIds: new Set(),
|
||||
styledLocalStyleSourceIds: new Set(),
|
||||
editableInstanceIds: new Set(["block", "item"]),
|
||||
copiedTemplateProps: new Map(),
|
||||
})
|
||||
).toEqual({
|
||||
success: false,
|
||||
@@ -2210,6 +2234,184 @@ describe("content mode permissions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("allows exact fixed template props only on a newly copied subtree", () => {
|
||||
const sourceImage: Instance = {
|
||||
type: "instance",
|
||||
id: "source-image",
|
||||
component: "Image",
|
||||
children: [],
|
||||
};
|
||||
const sourceWidth: Prop = {
|
||||
id: "source-width",
|
||||
instanceId: sourceImage.id,
|
||||
name: "width",
|
||||
type: "number",
|
||||
value: 120,
|
||||
};
|
||||
const capabilities = getContentModeCapabilities({
|
||||
instances: new Map([
|
||||
[
|
||||
"block",
|
||||
{
|
||||
type: "instance",
|
||||
id: "block",
|
||||
component: blockComponent,
|
||||
children: [{ type: "id", value: "templates" }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"templates",
|
||||
{
|
||||
type: "instance",
|
||||
id: "templates",
|
||||
component: blockTemplateComponent,
|
||||
children: [{ type: "id", value: sourceImage.id }],
|
||||
},
|
||||
],
|
||||
[sourceImage.id, sourceImage],
|
||||
]),
|
||||
metas: new Map([
|
||||
[
|
||||
"Image",
|
||||
{
|
||||
props: {
|
||||
width: { type: "number", control: "number", required: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
props: new Map([[sourceWidth.id, sourceWidth]]),
|
||||
styleSources,
|
||||
});
|
||||
const copiedWidth: Prop = {
|
||||
...sourceWidth,
|
||||
id: "copied-width",
|
||||
instanceId: "copied-image",
|
||||
};
|
||||
const insert = (width: Prop): ContentModePatchTransaction => ({
|
||||
payload: [
|
||||
{
|
||||
namespace: "instances",
|
||||
patches: [
|
||||
{
|
||||
op: "add",
|
||||
path: ["block", "children", 0],
|
||||
value: { type: "id", value: "copied-image" },
|
||||
},
|
||||
{
|
||||
op: "add",
|
||||
path: ["copied-image"],
|
||||
value: { ...sourceImage, id: "copied-image" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "props",
|
||||
patches: [{ op: "add", path: [width.id], value: width }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const inserted = applyContentModeTransaction({
|
||||
capabilities,
|
||||
transaction: insert(copiedWidth),
|
||||
});
|
||||
expect(inserted.success).toBe(true);
|
||||
if (inserted.success) {
|
||||
expect(inserted.capabilities.props.get(copiedWidth.id)).toEqual(
|
||||
copiedWidth
|
||||
);
|
||||
expect(
|
||||
validateContentModeTransaction({
|
||||
capabilities: inserted.capabilities,
|
||||
transaction: transaction("props", [
|
||||
{
|
||||
op: "replace",
|
||||
path: [copiedWidth.id, "value"],
|
||||
value: 240,
|
||||
},
|
||||
]),
|
||||
}).success
|
||||
).toBe(false);
|
||||
}
|
||||
expect(
|
||||
validateContentModeTransaction({
|
||||
capabilities,
|
||||
transaction: insert({ ...copiedWidth, value: 240 }),
|
||||
}).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateContentModeTransaction({
|
||||
capabilities,
|
||||
transaction: insert({
|
||||
...copiedWidth,
|
||||
name: "class",
|
||||
type: "string",
|
||||
value: "arbitrary-class",
|
||||
}),
|
||||
}).success
|
||||
).toBe(false);
|
||||
const changedTree = insert(copiedWidth);
|
||||
changedTree.payload[0].patches[1] = {
|
||||
op: "add",
|
||||
path: ["copied-image"],
|
||||
value: { ...sourceImage, id: "copied-image", label: "Changed" },
|
||||
};
|
||||
expect(
|
||||
validateContentModeTransaction({
|
||||
capabilities,
|
||||
transaction: changedTree,
|
||||
}).success
|
||||
).toBe(false);
|
||||
const mismatchedKey = insert(copiedWidth);
|
||||
mismatchedKey.payload[1].patches[0].path = [sourceWidth.id];
|
||||
expect(
|
||||
validateContentModeTransaction({
|
||||
capabilities,
|
||||
transaction: mismatchedKey,
|
||||
}).success
|
||||
).toBe(false);
|
||||
expect(
|
||||
validateContentModeTransaction({
|
||||
capabilities,
|
||||
transaction: insert({ ...copiedWidth, id: sourceWidth.id }),
|
||||
}).success
|
||||
).toBe(false);
|
||||
const duplicateKey = insert(copiedWidth);
|
||||
duplicateKey.payload[1].patches.push(duplicateKey.payload[1].patches[0]);
|
||||
expect(
|
||||
validateContentModeTransaction({
|
||||
capabilities,
|
||||
transaction: duplicateKey,
|
||||
}).success
|
||||
).toBe(false);
|
||||
const otherBlockCapabilities = getContentModeCapabilities({
|
||||
instances: new Map([
|
||||
...capabilities.instances,
|
||||
[
|
||||
"other-block",
|
||||
{
|
||||
type: "instance" as const,
|
||||
id: "other-block",
|
||||
component: blockComponent,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
]),
|
||||
metas: capabilities.metas,
|
||||
props: capabilities.props,
|
||||
styleSources,
|
||||
});
|
||||
const crossBlockCopy = insert(copiedWidth);
|
||||
crossBlockCopy.payload[0].patches[0].path = ["other-block", "children", 0];
|
||||
expect(
|
||||
validateContentModeTransaction({
|
||||
capabilities: otherBlockCapabilities,
|
||||
transaction: crossBlockCopy,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("allows cleaning up any props from removed editable instances", () => {
|
||||
const capabilities = getContentModeCapabilities({
|
||||
instances,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Patch } from "immer";
|
||||
import equal from "fast-deep-equal";
|
||||
import { isRecord } from "../shared/type-utils";
|
||||
import {
|
||||
instance,
|
||||
@@ -70,6 +71,7 @@ type ContentModeTransactionContext = {
|
||||
selectedLocalStyleSourceIds: Set<StyleSource["id"]>;
|
||||
styledLocalStyleSourceIds: Set<StyleSource["id"]>;
|
||||
editableInstanceIds: Set<Instance["id"]>;
|
||||
copiedTemplateProps: Map<Patch, Prop>;
|
||||
};
|
||||
|
||||
export type ContentModeValidationResult =
|
||||
@@ -288,6 +290,222 @@ const getBlockTemplateChildIdsByInstanceId = (
|
||||
return childIdsByInstanceId;
|
||||
};
|
||||
|
||||
// Decide explicitly when a new Prop type is added whether its template default
|
||||
// can be retained without carrying a reference or behavior into content mode.
|
||||
export const isContentModeTemplateDefaultPropType = (
|
||||
propType: Prop["type"]
|
||||
) => {
|
||||
switch (propType) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
case "json":
|
||||
case "string[]":
|
||||
return true;
|
||||
case "asset":
|
||||
case "page":
|
||||
case "parameter":
|
||||
case "resource":
|
||||
case "expression":
|
||||
case "action":
|
||||
case "animationAction":
|
||||
return false;
|
||||
default:
|
||||
propType satisfies never;
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const contentModeNonCopyablePropTypes = new Set<Prop["type"]>([
|
||||
"resource",
|
||||
"parameter",
|
||||
"expression",
|
||||
"action",
|
||||
"animationAction",
|
||||
]);
|
||||
|
||||
const getMatchingTemplateInstanceIds = ({
|
||||
sourceId,
|
||||
targetId,
|
||||
originalInstances,
|
||||
instances,
|
||||
addedInstanceIds,
|
||||
}: {
|
||||
sourceId: Instance["id"];
|
||||
targetId: Instance["id"];
|
||||
originalInstances: Map<Instance["id"], Instance>;
|
||||
instances: Map<Instance["id"], Instance>;
|
||||
addedInstanceIds: Set<Instance["id"]>;
|
||||
}) => {
|
||||
const sourceIdsByTargetId = new Map<Instance["id"], Instance["id"]>();
|
||||
const matchTree = (sourceId: Instance["id"], targetId: Instance["id"]) => {
|
||||
if (addedInstanceIds.has(targetId) === false) {
|
||||
return false;
|
||||
}
|
||||
const source = originalInstances.get(sourceId);
|
||||
const target = instances.get(targetId);
|
||||
if (source === undefined || target === undefined) {
|
||||
return false;
|
||||
}
|
||||
const { id: _sourceId, children: sourceChildren, ...sourceFields } = source;
|
||||
const { id: _targetId, children: targetChildren, ...targetFields } = target;
|
||||
if (
|
||||
equal(sourceFields, targetFields) === false ||
|
||||
sourceChildren.length !== targetChildren.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
sourceIdsByTargetId.set(targetId, sourceId);
|
||||
for (let index = 0; index < sourceChildren.length; index += 1) {
|
||||
const sourceChild = sourceChildren[index];
|
||||
const targetChild = targetChildren[index];
|
||||
if (sourceChild.type === "id" && targetChild.type === "id") {
|
||||
if (matchTree(sourceChild.value, targetChild.value) === false) {
|
||||
return false;
|
||||
}
|
||||
} else if (equal(sourceChild, targetChild) === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return matchTree(sourceId, targetId) ? sourceIdsByTargetId : undefined;
|
||||
};
|
||||
|
||||
const getCopiedTemplateProps = ({
|
||||
originalInstances,
|
||||
originalProps,
|
||||
instances,
|
||||
addedInstanceIds,
|
||||
editableInstanceRootIds,
|
||||
transaction,
|
||||
}: {
|
||||
originalInstances: Map<Instance["id"], Instance>;
|
||||
originalProps: Props;
|
||||
instances: Map<Instance["id"], Instance>;
|
||||
addedInstanceIds: Set<Instance["id"]>;
|
||||
editableInstanceRootIds: Map<Instance["id"], Instance["id"]>;
|
||||
transaction: ContentModePatchTransaction;
|
||||
}) => {
|
||||
// Approve individual patches so later validation does not match props again.
|
||||
const copiedProps = new Map<Patch, Prop>();
|
||||
if (addedInstanceIds.size === 0) {
|
||||
return copiedProps;
|
||||
}
|
||||
|
||||
// A newly inserted tree may carry design-only props only when it is an exact
|
||||
// copy of an authored template in the same Content Block.
|
||||
const templateRootsByContentRootId = new Map<string, string[]>();
|
||||
for (const block of originalInstances.values()) {
|
||||
if (block.component !== blockComponent) {
|
||||
continue;
|
||||
}
|
||||
const bodyContainers = findContentBlockBodyContainers({
|
||||
blockInstance: block,
|
||||
instances: originalInstances,
|
||||
});
|
||||
const contentRootId =
|
||||
bodyContainers.length === 1 ? bodyContainers[0].id : block.id;
|
||||
for (const child of block.children ?? []) {
|
||||
if (child.type !== "id") {
|
||||
continue;
|
||||
}
|
||||
const container = originalInstances.get(child.value);
|
||||
if (container?.component !== blockTemplateComponent) {
|
||||
continue;
|
||||
}
|
||||
const roots = templateRootsByContentRootId.get(contentRootId) ?? [];
|
||||
roots.push(
|
||||
...container.children.flatMap((entry) =>
|
||||
entry.type === "id" ? [entry.value] : []
|
||||
)
|
||||
);
|
||||
templateRootsByContentRootId.set(contentRootId, roots);
|
||||
}
|
||||
}
|
||||
|
||||
const nestedAddedIds = new Set(
|
||||
Array.from(addedInstanceIds).flatMap(
|
||||
(id) =>
|
||||
instances
|
||||
.get(id)
|
||||
?.children.flatMap((child) =>
|
||||
child.type === "id" && addedInstanceIds.has(child.value)
|
||||
? [child.value]
|
||||
: []
|
||||
) ?? []
|
||||
)
|
||||
);
|
||||
const addedProps = transaction.payload.flatMap((change) =>
|
||||
change.namespace === "props"
|
||||
? change.patches.flatMap((patch) => {
|
||||
if (patch.op !== "add" || patch.path.length !== 1) {
|
||||
return [];
|
||||
}
|
||||
const parsed = prop.safeParse(patch.value);
|
||||
return parsed.success ? [{ patch, prop: parsed.data }] : [];
|
||||
})
|
||||
: []
|
||||
);
|
||||
const sourcePropsByInstanceId = new Map<string, Prop[]>();
|
||||
for (const sourceProp of originalProps.values()) {
|
||||
const sourceProps =
|
||||
sourcePropsByInstanceId.get(sourceProp.instanceId) ?? [];
|
||||
sourceProps.push(sourceProp);
|
||||
sourcePropsByInstanceId.set(sourceProp.instanceId, sourceProps);
|
||||
}
|
||||
|
||||
for (const targetRootId of addedInstanceIds) {
|
||||
if (nestedAddedIds.has(targetRootId)) {
|
||||
continue;
|
||||
}
|
||||
const contentRootId = editableInstanceRootIds.get(targetRootId);
|
||||
for (const sourceRootId of templateRootsByContentRootId.get(
|
||||
contentRootId ?? ""
|
||||
) ?? []) {
|
||||
const sourceIdsByTargetId = getMatchingTemplateInstanceIds({
|
||||
sourceId: sourceRootId,
|
||||
targetId: targetRootId,
|
||||
originalInstances,
|
||||
instances,
|
||||
addedInstanceIds,
|
||||
});
|
||||
if (sourceIdsByTargetId === undefined) {
|
||||
continue;
|
||||
}
|
||||
const allAddedPropsMatch = addedProps.every(({ prop: addedProp }) => {
|
||||
const sourceId = sourceIdsByTargetId.get(addedProp.instanceId);
|
||||
if (sourceId === undefined) {
|
||||
return true;
|
||||
}
|
||||
return (sourcePropsByInstanceId.get(sourceId) ?? []).some(
|
||||
(sourceProp) =>
|
||||
sourceProp.name === addedProp.name &&
|
||||
sourceProp.type === addedProp.type &&
|
||||
sourceProp.required === addedProp.required &&
|
||||
equal(sourceProp.value, addedProp.value)
|
||||
);
|
||||
});
|
||||
if (allAddedPropsMatch === false) {
|
||||
continue;
|
||||
}
|
||||
for (const { patch, prop } of addedProps) {
|
||||
if (
|
||||
sourceIdsByTargetId.has(prop.instanceId) &&
|
||||
prop.id === patch.path[0] &&
|
||||
originalProps.has(prop.id) === false &&
|
||||
isContentModeTemplateDefaultPropType(prop.type)
|
||||
) {
|
||||
copiedProps.set(patch, prop);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return copiedProps;
|
||||
};
|
||||
|
||||
const getInstancePropMetas = ({
|
||||
capabilities,
|
||||
instance,
|
||||
@@ -421,14 +639,6 @@ const isContentModePropForInstance = ({
|
||||
prop: Prop;
|
||||
}) => getContentModePropEligibility({ capabilities, instance, prop }).editable;
|
||||
|
||||
const contentModeNonCopyablePropTypes = new Set<Prop["type"]>([
|
||||
"resource",
|
||||
"parameter",
|
||||
"expression",
|
||||
"action",
|
||||
"animationAction",
|
||||
]);
|
||||
|
||||
export const isContentModeCopyableProp = ({
|
||||
capabilities,
|
||||
prop,
|
||||
@@ -602,6 +812,12 @@ const validatePropPatch = (
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const copiedProp = context.copiedTemplateProps.get(patch);
|
||||
if (copiedProp !== undefined && capabilities.props.has(propId) === false) {
|
||||
capabilities.props.set(propId, copiedProp);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Prop "${propId}" is not editable in content mode.`,
|
||||
@@ -1621,6 +1837,7 @@ export const applyContentModeTransaction = ({
|
||||
selectedLocalStyleSourceIds: new Set(),
|
||||
styledLocalStyleSourceIds: new Set(),
|
||||
editableInstanceIds: new Set(),
|
||||
copiedTemplateProps: new Map(),
|
||||
};
|
||||
|
||||
for (const change of transaction.payload) {
|
||||
@@ -1662,15 +1879,24 @@ export const applyContentModeTransaction = ({
|
||||
}
|
||||
}
|
||||
|
||||
context.editableInstanceIds = getContentModeEditableInstanceIds({
|
||||
const editableInstanceRootIds = getContentModeEditableInstanceRootIds({
|
||||
contentRootIds: transactionCapabilities.contentRootIds,
|
||||
instances: transactionCapabilities.instances,
|
||||
});
|
||||
context.editableInstanceIds = new Set(editableInstanceRootIds.keys());
|
||||
const frontmatterTargets = getContentModeFrontmatterBoundTargets({
|
||||
instances: transactionCapabilities.instances,
|
||||
props: transactionCapabilities.props,
|
||||
});
|
||||
transactionCapabilities.editableInstanceIds = context.editableInstanceIds;
|
||||
context.copiedTemplateProps = getCopiedTemplateProps({
|
||||
originalInstances: capabilities.instances,
|
||||
originalProps: capabilities.props,
|
||||
instances: transactionCapabilities.instances,
|
||||
addedInstanceIds: context.addedInstanceIds,
|
||||
editableInstanceRootIds,
|
||||
transaction,
|
||||
});
|
||||
transactionCapabilities.frontmatterBoundPropIds = frontmatterTargets.propIds;
|
||||
transactionCapabilities.frontmatterBoundTextInstanceIds =
|
||||
frontmatterTargets.textInstanceIds;
|
||||
|
||||
@@ -1101,6 +1101,31 @@ describe("rich text tree", () => {
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test("does not edit mixed text and image as rich text", () => {
|
||||
expect(
|
||||
isRichTextTree({
|
||||
...renderData(
|
||||
<Paragraph ws:id="instanceId">
|
||||
Heading <Image ws:id="imageId" />
|
||||
</Paragraph>
|
||||
),
|
||||
metas: defaultMetas,
|
||||
instanceId: "instanceId",
|
||||
})
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
isRichTextTree({
|
||||
...renderData(
|
||||
<ws.element ws:tag="h2" ws:id="instanceId">
|
||||
Heading <ws.element ws:tag="img" ws:id="imageId" />
|
||||
</ws.element>
|
||||
),
|
||||
metas: defaultMetas,
|
||||
instanceId: "instanceId",
|
||||
})
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
test("rich text content tags can be edited", () => {
|
||||
expect(
|
||||
isRichTextTree({
|
||||
|
||||
@@ -736,11 +736,6 @@ export const isRichTextTree = ({
|
||||
if (instance.children.length === 0) {
|
||||
return isRichText;
|
||||
}
|
||||
for (const child of instance.children) {
|
||||
if (child.type === "text" || child.type === "expression") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const contentTags = findContentTags({
|
||||
instances,
|
||||
props,
|
||||
@@ -752,11 +747,20 @@ export const isRichTextTree = ({
|
||||
instances,
|
||||
instance,
|
||||
});
|
||||
const supportsRichTextContent =
|
||||
setIsSubsetOf(contentTags, richTextContentTags) &&
|
||||
setIsSubsetOf(contentComponents, richTextContentComponents);
|
||||
if (
|
||||
instance.children.some(
|
||||
(child) => child.type === "text" || child.type === "expression"
|
||||
)
|
||||
) {
|
||||
return contentTags.has("img") === false;
|
||||
}
|
||||
return (
|
||||
isRichText &&
|
||||
// rich text must contain only supported elements in editor
|
||||
setIsSubsetOf(contentTags, richTextContentTags) &&
|
||||
setIsSubsetOf(contentComponents, richTextContentComponents) &&
|
||||
supportsRichTextContent &&
|
||||
// rich text cannot contain only span and only link
|
||||
// those links and spans are containers in such cases
|
||||
!setIsSubsetOf(contentTags, new Set(richTextPlaceholders.keys()))
|
||||
|
||||
@@ -2254,6 +2254,34 @@ test("delete unused data variables is a noop when all variables are used", () =>
|
||||
});
|
||||
|
||||
describe("createResourceValue", () => {
|
||||
test("rejects unsupported resource fields instead of silently discarding updates", () => {
|
||||
const unsupported = {
|
||||
email: { recipientMode: "custom", recipients: ["test@example.com"] },
|
||||
};
|
||||
const update = resourceFieldsUpdateInput.safeParse({
|
||||
name: "Updated",
|
||||
...unsupported,
|
||||
});
|
||||
expect(update.success).toBe(false);
|
||||
if (update.success === false) {
|
||||
expect(update.error.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "unrecognized_keys",
|
||||
keys: ["email"],
|
||||
})
|
||||
);
|
||||
}
|
||||
expect(
|
||||
resourceFieldsInput.safeParse({
|
||||
name: "Users",
|
||||
method: "get",
|
||||
url: '"https://example.com/users"',
|
||||
headers: [],
|
||||
...unsupported,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("parse resource create and update inputs used by the API", () => {
|
||||
expect(
|
||||
resourceFieldsInput.parse({
|
||||
|
||||
@@ -1744,17 +1744,20 @@ const resourceExpressionEntryInput = z.object({
|
||||
value: resourceExpressionInput,
|
||||
});
|
||||
|
||||
const resourceFieldsInputBase = resource.omit({ id: true }).extend({
|
||||
control: z.enum(["system", "graphql"]).optional(),
|
||||
url: z.preprocess(
|
||||
(value) =>
|
||||
typeof value === "string" ? normalizeResourceUrlInput(value) : value,
|
||||
z.string()
|
||||
),
|
||||
searchParams: z.array(resourceExpressionEntryInput).optional(),
|
||||
headers: z.array(resourceExpressionEntryInput),
|
||||
body: resourceExpressionInput.optional(),
|
||||
});
|
||||
const resourceFieldsInputBase = resource
|
||||
.omit({ id: true })
|
||||
.extend({
|
||||
control: z.enum(["system", "graphql"]).optional(),
|
||||
url: z.preprocess(
|
||||
(value) =>
|
||||
typeof value === "string" ? normalizeResourceUrlInput(value) : value,
|
||||
z.string()
|
||||
),
|
||||
searchParams: z.array(resourceExpressionEntryInput).optional(),
|
||||
headers: z.array(resourceExpressionEntryInput),
|
||||
body: resourceExpressionInput.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const resourceFieldsInput = resourceFieldsInputBase.superRefine(
|
||||
(fields, context) => {
|
||||
|
||||
@@ -821,6 +821,14 @@ export const insertWebstudioFragmentCopy = ({
|
||||
maskedIdByName.set(dataSource.name, dataSource.id);
|
||||
}
|
||||
const newResourceIds = new Map<Resource["id"], Resource["id"]>();
|
||||
const getNewResourceId = (resourceId: Resource["id"]) => {
|
||||
let newResourceId = newResourceIds.get(resourceId);
|
||||
if (newResourceId === undefined) {
|
||||
newResourceId = createId();
|
||||
newResourceIds.set(resourceId, newResourceId);
|
||||
}
|
||||
return newResourceId;
|
||||
};
|
||||
if (contentMode === false) {
|
||||
for (let dataSource of fragment.dataSources) {
|
||||
const scopeInstanceId = dataSource.scopeInstanceId ?? "";
|
||||
@@ -843,9 +851,7 @@ export const insertWebstudioFragmentCopy = ({
|
||||
dataSource.scopeInstanceId =
|
||||
newInstanceIds.get(scopeInstanceId) ?? scopeInstanceId;
|
||||
if (dataSource.type === "resource") {
|
||||
const newResourceId = createId();
|
||||
newResourceIds.set(dataSource.resourceId, newResourceId);
|
||||
dataSource.resourceId = newResourceId;
|
||||
dataSource.resourceId = getNewResourceId(dataSource.resourceId);
|
||||
}
|
||||
dataSources.set(dataSource.id, dataSource);
|
||||
}
|
||||
@@ -897,9 +903,7 @@ export const insertWebstudioFragmentCopy = ({
|
||||
prop.value = newDataSourceIds.get(prop.value) ?? prop.value;
|
||||
}
|
||||
if (prop.type === "resource") {
|
||||
const newResourceId = createId();
|
||||
newResourceIds.set(prop.value, newResourceId);
|
||||
prop.value = newResourceId;
|
||||
prop.value = getNewResourceId(prop.value);
|
||||
}
|
||||
props.set(prop.id, prop);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
getStyleDeclKey,
|
||||
ROOT_INSTANCE_ID,
|
||||
type DataSource,
|
||||
type Instance,
|
||||
type Prop,
|
||||
type Resource,
|
||||
type StyleDecl,
|
||||
type WebstudioData,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
cloneInstance,
|
||||
cloneInstanceInput,
|
||||
duplicateInstanceAfterItself,
|
||||
duplicateInstanceAfterItselfMutable,
|
||||
} from "./instance-duplicate";
|
||||
import { applyBuilderPatchPayloadMutable } from "../state/patch";
|
||||
|
||||
const createInstance = (
|
||||
id: Instance["id"],
|
||||
@@ -67,6 +74,16 @@ const createWebstudioData = (): WebstudioData => ({
|
||||
},
|
||||
});
|
||||
|
||||
const applyMutation = (
|
||||
data: WebstudioData,
|
||||
mutation: ReturnType<typeof cloneInstance>
|
||||
) => {
|
||||
applyBuilderPatchPayloadMutable(
|
||||
(namespace) => data[namespace as keyof WebstudioData],
|
||||
mutation.payload
|
||||
);
|
||||
};
|
||||
|
||||
const createStyleDecl = (
|
||||
styleSourceId: string,
|
||||
property: StyleDecl["property"],
|
||||
@@ -123,6 +140,242 @@ test("duplicates an instance into another parent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not clone the shared global root into a page", () => {
|
||||
const data = createWebstudioData();
|
||||
data.instances.set(
|
||||
ROOT_INSTANCE_ID,
|
||||
createInstance(ROOT_INSTANCE_ID, [{ type: "id", value: "parent" }])
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
cloneInstance(
|
||||
data,
|
||||
{
|
||||
sourceInstanceId: ROOT_INSTANCE_ID,
|
||||
targetParentInstanceId: "parent",
|
||||
},
|
||||
{ createId: () => "unused", projectId: "project-id" }
|
||||
)
|
||||
).toThrow("Source instance could not be cloned");
|
||||
expect(data.instances.get("parent")?.children).toEqual([
|
||||
{ type: "id", value: "source" },
|
||||
{ type: "id", value: "sibling" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("clones into the source parent by default and appends the full subtree", () => {
|
||||
const data = createWebstudioData();
|
||||
const ids = ["source-copy", "child-copy"];
|
||||
|
||||
const mutation = cloneInstance(
|
||||
data,
|
||||
{ sourceInstanceId: "source" },
|
||||
{
|
||||
createId: () => ids.shift() ?? "missing",
|
||||
projectId: "project-id",
|
||||
}
|
||||
);
|
||||
|
||||
expect(mutation.result).toEqual({
|
||||
instanceId: "source-copy",
|
||||
instanceIds: ["source-copy", "child-copy"],
|
||||
});
|
||||
expect(data.instances.get("parent")?.children).toEqual([
|
||||
{ type: "id", value: "source" },
|
||||
{ type: "id", value: "sibling" },
|
||||
]);
|
||||
|
||||
applyMutation(data, mutation);
|
||||
|
||||
expect(data.instances.get("parent")?.children).toEqual([
|
||||
{ type: "id", value: "source" },
|
||||
{ type: "id", value: "sibling" },
|
||||
{ type: "id", value: "source-copy" },
|
||||
]);
|
||||
expect(data.instances.get("source-copy")).toEqual(
|
||||
createInstance("source-copy", [{ type: "id", value: "child-copy" }])
|
||||
);
|
||||
expect(data.instances.get("child-copy")).toEqual(
|
||||
createInstance("child-copy")
|
||||
);
|
||||
});
|
||||
|
||||
test("clones into an explicit parent at the requested index", () => {
|
||||
const data = createWebstudioData();
|
||||
data.instances.set(
|
||||
"target",
|
||||
createInstance("target", [{ type: "id", value: "sibling" }])
|
||||
);
|
||||
const ids = ["source-copy", "child-copy"];
|
||||
|
||||
const mutation = cloneInstance(
|
||||
data,
|
||||
{
|
||||
sourceInstanceId: "source",
|
||||
targetParentInstanceId: "target",
|
||||
insertIndex: 0,
|
||||
},
|
||||
{
|
||||
createId: () => ids.shift() ?? "missing",
|
||||
projectId: "project-id",
|
||||
}
|
||||
);
|
||||
|
||||
applyMutation(data, mutation);
|
||||
|
||||
expect(data.instances.get("parent")?.children).toEqual([
|
||||
{ type: "id", value: "source" },
|
||||
{ type: "id", value: "sibling" },
|
||||
]);
|
||||
expect(data.instances.get("target")?.children).toEqual([
|
||||
{ type: "id", value: "source-copy" },
|
||||
{ type: "id", value: "sibling" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reuses one cloned resource for all cloned bindings", () => {
|
||||
const data = createWebstudioData();
|
||||
const resource: Resource = {
|
||||
id: "shared-resource",
|
||||
name: "Shared resource",
|
||||
method: "get",
|
||||
url: '"https://example.com"',
|
||||
headers: [],
|
||||
};
|
||||
const dataSource: DataSource = {
|
||||
id: "resource-data-source",
|
||||
scopeInstanceId: "child",
|
||||
name: "sharedResource",
|
||||
type: "resource",
|
||||
resourceId: resource.id,
|
||||
};
|
||||
const props: Prop[] = ["source", "child"].map((instanceId, index) => ({
|
||||
id: `resource-prop-${index}`,
|
||||
instanceId,
|
||||
name: `resource${index}`,
|
||||
type: "resource",
|
||||
value: resource.id,
|
||||
}));
|
||||
data.resources.set(resource.id, resource);
|
||||
data.dataSources.set(dataSource.id, dataSource);
|
||||
for (const prop of props) {
|
||||
data.props.set(prop.id, prop);
|
||||
}
|
||||
|
||||
let idIndex = 0;
|
||||
const mutation = cloneInstance(
|
||||
data,
|
||||
{ sourceInstanceId: "source" },
|
||||
{
|
||||
createId: () => `copy-${idIndex++}`,
|
||||
projectId: "project-id",
|
||||
}
|
||||
);
|
||||
applyMutation(data, mutation);
|
||||
|
||||
const clonedInstanceIds = new Set(mutation.result.instanceIds);
|
||||
const clonedDataSources = [...data.dataSources.values()].filter(
|
||||
(dataSource) =>
|
||||
dataSource.type === "resource" &&
|
||||
clonedInstanceIds.has(dataSource.scopeInstanceId ?? "")
|
||||
);
|
||||
const clonedProps = [...data.props.values()].filter(
|
||||
(prop) => clonedInstanceIds.has(prop.instanceId) && prop.type === "resource"
|
||||
);
|
||||
const clonedResourceIds = [
|
||||
...clonedDataSources.flatMap((dataSource) =>
|
||||
dataSource.type === "resource" ? [dataSource.resourceId] : []
|
||||
),
|
||||
...clonedProps.flatMap((prop) =>
|
||||
prop.type === "resource" ? [prop.value] : []
|
||||
),
|
||||
];
|
||||
|
||||
expect(clonedDataSources).toHaveLength(1);
|
||||
expect(clonedProps).toHaveLength(2);
|
||||
expect(new Set(clonedResourceIds)).toHaveLength(1);
|
||||
const clonedResourceId = clonedResourceIds[0];
|
||||
if (clonedResourceId === undefined) {
|
||||
throw new Error("Expected cloned resource references");
|
||||
}
|
||||
expect(clonedResourceId).not.toBe(resource.id);
|
||||
expect(data.resources.get(clonedResourceId)).toEqual({
|
||||
...resource,
|
||||
id: clonedResourceId,
|
||||
});
|
||||
});
|
||||
|
||||
test("renames a cloned template when its name already exists", () => {
|
||||
const data = createWebstudioData();
|
||||
data.instances.set("parent", {
|
||||
...data.instances.get("parent")!,
|
||||
component: "ws:block-template",
|
||||
});
|
||||
data.instances.set("source", {
|
||||
...data.instances.get("source")!,
|
||||
component: "ws:element",
|
||||
tag: "h2",
|
||||
name: "Card",
|
||||
});
|
||||
const ids = ["source-copy", "child-copy"];
|
||||
|
||||
const mutation = cloneInstance(
|
||||
data,
|
||||
{ sourceInstanceId: "source" },
|
||||
{
|
||||
createId: () => ids.shift() ?? "missing",
|
||||
projectId: "project-id",
|
||||
}
|
||||
);
|
||||
applyMutation(data, mutation);
|
||||
|
||||
expect(data.instances.get("source-copy")?.name).toBe("Card2");
|
||||
});
|
||||
|
||||
test("rejects missing clone targets and insert indexes beyond the end", () => {
|
||||
const data = createWebstudioData();
|
||||
const context = { createId: () => "unused", projectId: "project-id" };
|
||||
data.instances.set("detached", createInstance("detached"));
|
||||
|
||||
expect(() =>
|
||||
cloneInstance(data, { sourceInstanceId: "missing" }, context)
|
||||
).toThrow("Instance not found");
|
||||
expect(() =>
|
||||
cloneInstance(data, { sourceInstanceId: "detached" }, context)
|
||||
).toThrow("Instance not found");
|
||||
expect(() =>
|
||||
cloneInstance(
|
||||
data,
|
||||
{ sourceInstanceId: "source", targetParentInstanceId: "missing" },
|
||||
context
|
||||
)
|
||||
).toThrow("Instance not found");
|
||||
expect(() =>
|
||||
cloneInstance(
|
||||
data,
|
||||
{
|
||||
sourceInstanceId: "source",
|
||||
targetParentInstanceId: "parent",
|
||||
insertIndex: 3,
|
||||
},
|
||||
context
|
||||
)
|
||||
).toThrow("Insert index is outside parent children");
|
||||
|
||||
expect(data.instances.get("parent")?.children).toEqual([
|
||||
{ type: "id", value: "source" },
|
||||
{ type: "id", value: "sibling" },
|
||||
]);
|
||||
expect(data.instances.has("unused")).toBe(false);
|
||||
expect(
|
||||
cloneInstanceInput.safeParse({
|
||||
sourceInstanceId: "source",
|
||||
targetParentInstanceId: "parent",
|
||||
insertIndex: -1,
|
||||
}).success
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("preserves legacy HtmlEmbed code when duplicating an instance", () => {
|
||||
const data = createData();
|
||||
data.instances.set("source", {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Instance, WebstudioData } from "@webstudio-is/sdk";
|
||||
import {
|
||||
assignUniqueBlockTemplateNamesMutable,
|
||||
ROOT_INSTANCE_ID,
|
||||
type Instance,
|
||||
type WebstudioData,
|
||||
} from "@webstudio-is/sdk";
|
||||
import type { BuilderState } from "../state/builder-state";
|
||||
import type { BuilderRuntimeContext } from "./context";
|
||||
import { findAvailableVariables, produceWebstudioDataMutation } from "./data";
|
||||
@@ -7,7 +12,12 @@ import {
|
||||
extractWebstudioFragment,
|
||||
insertWebstudioFragmentCopy,
|
||||
} from "./fragment";
|
||||
import { findChildReferenceIndex } from "./instances";
|
||||
import {
|
||||
findChildReferenceIndex,
|
||||
findParentInstanceReference,
|
||||
insertIndexInput,
|
||||
} from "./instances";
|
||||
import { componentMetas } from "@webstudio-is/sdk-components-registry/metas";
|
||||
import { isAutoGridPlacement, resetGridChildPlacement } from "./style-utils";
|
||||
import { createRuntimeMutation } from "./mutation";
|
||||
import { getSlotFragmentDropTargetMutable } from "./slot";
|
||||
@@ -18,6 +28,12 @@ export const duplicateInstanceAfterItselfInput = z.object({
|
||||
parentInstanceId: z.string(),
|
||||
});
|
||||
|
||||
export const cloneInstanceInput = z.object({
|
||||
sourceInstanceId: z.string(),
|
||||
targetParentInstanceId: z.string().optional(),
|
||||
insertIndex: insertIndexInput.optional(),
|
||||
});
|
||||
|
||||
export const instanceDuplicateNamespaces = [
|
||||
"pages",
|
||||
"instances",
|
||||
@@ -48,6 +64,102 @@ const getRequiredInstanceDuplicateState = (state: InstanceDuplicateState) => {
|
||||
return state as WebstudioData;
|
||||
};
|
||||
|
||||
const copyInstanceSubtreeMutable = ({
|
||||
data,
|
||||
sourceInstanceId,
|
||||
targetParentInstanceId,
|
||||
projectId,
|
||||
createId,
|
||||
}: {
|
||||
data: Omit<WebstudioData, "pages">;
|
||||
sourceInstanceId: Instance["id"];
|
||||
targetParentInstanceId: Instance["id"];
|
||||
projectId: string;
|
||||
createId?: () => string;
|
||||
}) =>
|
||||
insertWebstudioFragmentCopy({
|
||||
data,
|
||||
fragment: extractWebstudioFragment(data, sourceInstanceId),
|
||||
availableVariables: findAvailableVariables({
|
||||
...data,
|
||||
startingInstanceId: targetParentInstanceId,
|
||||
}),
|
||||
projectId,
|
||||
createId,
|
||||
}).newInstanceIds;
|
||||
|
||||
export const cloneInstance = (
|
||||
state: InstanceDuplicateState,
|
||||
input: z.infer<typeof cloneInstanceInput>,
|
||||
context: BuilderRuntimeContext
|
||||
) => {
|
||||
const before = getRequiredInstanceDuplicateState(state);
|
||||
const source = before.instances.get(input.sourceInstanceId);
|
||||
if (source === undefined) {
|
||||
return throwBuilderRuntimeError("NOT_FOUND", "Instance not found");
|
||||
}
|
||||
if (source.id === ROOT_INSTANCE_ID) {
|
||||
return throwBuilderRuntimeError(
|
||||
"BAD_REQUEST",
|
||||
"Source instance could not be cloned"
|
||||
);
|
||||
}
|
||||
const targetParent =
|
||||
input.targetParentInstanceId === undefined
|
||||
? findParentInstanceReference(before.instances, source.id)?.instance
|
||||
: before.instances.get(input.targetParentInstanceId);
|
||||
if (targetParent === undefined) {
|
||||
return throwBuilderRuntimeError("NOT_FOUND", "Instance not found");
|
||||
}
|
||||
const insertIndex = input.insertIndex ?? targetParent.children.length;
|
||||
if (insertIndex > targetParent.children.length) {
|
||||
return throwBuilderRuntimeError(
|
||||
"BAD_REQUEST",
|
||||
"Insert index is outside parent children"
|
||||
);
|
||||
}
|
||||
|
||||
let clonedRootId: Instance["id"] | undefined;
|
||||
let clonedInstanceIds: Instance["id"][] = [];
|
||||
const { payload } = produceWebstudioDataMutation(before, (draft) => {
|
||||
const newInstanceIds = copyInstanceSubtreeMutable({
|
||||
data: draft,
|
||||
sourceInstanceId: source.id,
|
||||
targetParentInstanceId: targetParent.id,
|
||||
projectId: context.projectId ?? "",
|
||||
createId: context.createId,
|
||||
});
|
||||
clonedRootId = newInstanceIds.get(source.id);
|
||||
if (clonedRootId === undefined) {
|
||||
return;
|
||||
}
|
||||
clonedInstanceIds = [...newInstanceIds.values()].filter(
|
||||
(instanceId) => instanceId !== ROOT_INSTANCE_ID
|
||||
);
|
||||
assignUniqueBlockTemplateNamesMutable({
|
||||
instanceIds: [clonedRootId],
|
||||
parent: targetParent,
|
||||
instances: draft.instances,
|
||||
components: componentMetas.keys(),
|
||||
});
|
||||
draft.instances.get(targetParent.id)?.children.splice(insertIndex, 0, {
|
||||
type: "id",
|
||||
value: clonedRootId,
|
||||
});
|
||||
});
|
||||
if (clonedRootId === undefined) {
|
||||
return throwBuilderRuntimeError(
|
||||
"BAD_REQUEST",
|
||||
"Source instance could not be cloned"
|
||||
);
|
||||
}
|
||||
return createRuntimeMutation({
|
||||
payload,
|
||||
result: { instanceId: clonedRootId, instanceIds: clonedInstanceIds },
|
||||
invalidatesNamespaces: instanceDuplicateNamespaces,
|
||||
});
|
||||
};
|
||||
|
||||
export const duplicateInstanceAfterItselfMutable = ({
|
||||
data,
|
||||
sourceInstanceId,
|
||||
@@ -61,14 +173,10 @@ export const duplicateInstanceAfterItselfMutable = ({
|
||||
projectId: string;
|
||||
createId?: () => string;
|
||||
}) => {
|
||||
const fragment = extractWebstudioFragment(data, sourceInstanceId);
|
||||
const { newInstanceIds } = insertWebstudioFragmentCopy({
|
||||
const newInstanceIds = copyInstanceSubtreeMutable({
|
||||
data,
|
||||
fragment,
|
||||
availableVariables: findAvailableVariables({
|
||||
...data,
|
||||
startingInstanceId: parentInstanceId,
|
||||
}),
|
||||
sourceInstanceId,
|
||||
targetParentInstanceId: parentInstanceId,
|
||||
projectId,
|
||||
createId,
|
||||
});
|
||||
|
||||
@@ -19,12 +19,10 @@ import {
|
||||
canUnwrapInstancePath,
|
||||
canWrapInstance,
|
||||
canConvertInstance,
|
||||
cloneInstanceSubtree,
|
||||
cloneInstanceWithNewIds,
|
||||
convertInstance,
|
||||
createInstanceAppendPayload,
|
||||
createInstanceChild,
|
||||
createInstanceClonePayload,
|
||||
createInstanceCleanupPayload,
|
||||
createInstanceDeletePayload,
|
||||
createInstanceMovePatches,
|
||||
@@ -418,157 +416,6 @@ test("collects shared descendants when all owners are requested", () => {
|
||||
new Set(["first-root", "shared-child", "shared-leaf", "second-root"])
|
||||
);
|
||||
});
|
||||
const createCloneInstance = (
|
||||
id: Instance["id"],
|
||||
children: Instance["children"] = []
|
||||
): Instance => ({
|
||||
type: "instance",
|
||||
id,
|
||||
component: elementComponent,
|
||||
children,
|
||||
});
|
||||
|
||||
test("creates instance clone payload with child references and props", () => {
|
||||
const targetParent = createCloneInstance("parent", []);
|
||||
const props: Prop[] = [
|
||||
{
|
||||
id: "prop",
|
||||
instanceId: "source",
|
||||
name: "title",
|
||||
type: "expression",
|
||||
value: "document.frontmatter.title",
|
||||
mode: "readwrite",
|
||||
},
|
||||
];
|
||||
const ids = ["source-copy", "child-copy", "prop-copy"];
|
||||
|
||||
const result = createInstanceClonePayload({
|
||||
instances: new Map([
|
||||
[
|
||||
"source",
|
||||
createCloneInstance("source", [
|
||||
{ type: "id", value: "child" },
|
||||
{ type: "text", value: "Text" },
|
||||
{
|
||||
type: "expression",
|
||||
value: "document.frontmatter.title",
|
||||
mode: "readwrite",
|
||||
},
|
||||
]),
|
||||
],
|
||||
["child", createCloneInstance("child")],
|
||||
[targetParent.id, targetParent],
|
||||
]),
|
||||
sourceInstanceId: "source",
|
||||
targetParent,
|
||||
insertIndex: 0,
|
||||
props,
|
||||
styleSourceSelections: [],
|
||||
styleSources: [],
|
||||
styles: new Map(),
|
||||
createId: () => ids.shift() ?? "missing",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
clonedRootId: "source-copy",
|
||||
clonedInstanceIds: ["source-copy", "child-copy"],
|
||||
payload: [
|
||||
{
|
||||
namespace: "instances",
|
||||
patches: [
|
||||
{
|
||||
op: "add",
|
||||
path: ["source-copy"],
|
||||
value: createCloneInstance("source-copy", [
|
||||
{ type: "id", value: "child-copy" },
|
||||
{ type: "text", value: "Text" },
|
||||
{
|
||||
type: "expression",
|
||||
value: "document.frontmatter.title",
|
||||
mode: "readwrite",
|
||||
},
|
||||
]),
|
||||
},
|
||||
{
|
||||
op: "add",
|
||||
path: ["child-copy"],
|
||||
value: createCloneInstance("child-copy"),
|
||||
},
|
||||
{
|
||||
op: "add",
|
||||
path: ["parent", "children", 0],
|
||||
value: { type: "id", value: "source-copy" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "props",
|
||||
patches: [
|
||||
{
|
||||
op: "add",
|
||||
path: [expect.any(String)],
|
||||
value: {
|
||||
...props[0],
|
||||
id: expect.any(String),
|
||||
instanceId: "source-copy",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves asset references when cloning instance props", () => {
|
||||
const ids = ["source-copy", "asset-prop-copy"];
|
||||
const result = createInstanceClonePayload({
|
||||
instances: new Map([
|
||||
["source", createCloneInstance("source")],
|
||||
["parent", createCloneInstance("parent")],
|
||||
]),
|
||||
sourceInstanceId: "source",
|
||||
targetParent: createCloneInstance("parent"),
|
||||
insertIndex: 0,
|
||||
props: [
|
||||
{
|
||||
id: "image-src",
|
||||
instanceId: "source",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "hero-image",
|
||||
},
|
||||
],
|
||||
styleSourceSelections: [],
|
||||
styleSources: [],
|
||||
styles: new Map(),
|
||||
createId: () => ids.shift() ?? "missing",
|
||||
});
|
||||
|
||||
if (result === undefined) {
|
||||
throw new Error(
|
||||
"Expected valid source instance to produce a clone payload"
|
||||
);
|
||||
}
|
||||
const propsPayload = result.payload.find(
|
||||
(item) => item.namespace === "props"
|
||||
);
|
||||
expect(propsPayload).toEqual({
|
||||
namespace: "props",
|
||||
patches: [
|
||||
{
|
||||
op: "add",
|
||||
path: [expect.any(String)],
|
||||
value: {
|
||||
id: expect.any(String),
|
||||
instanceId: "source-copy",
|
||||
name: "src",
|
||||
type: "asset",
|
||||
value: "hero-image",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
describe("text content utils", () => {
|
||||
test("creates text and expression children", () => {
|
||||
expect(createTextContentChild({ type: "text", value: "Hello" })).toEqual({
|
||||
@@ -2277,44 +2124,6 @@ test("clone instance with mapped id children", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("clones an instance subtree with new ids", () => {
|
||||
const instances = new Map([
|
||||
[
|
||||
"root",
|
||||
createInstance("root", elementComponent, [
|
||||
{ type: "id", value: "child" },
|
||||
{ type: "text", value: "Text" },
|
||||
]),
|
||||
],
|
||||
["child", createInstance("child", elementComponent)],
|
||||
]);
|
||||
const ids = ["root-copy", "child-copy"];
|
||||
|
||||
expect(
|
||||
cloneInstanceSubtree({
|
||||
instances,
|
||||
rootInstanceId: "root",
|
||||
createId: () => ids.shift() ?? "missing",
|
||||
})
|
||||
).toEqual({
|
||||
instanceIds: ["root", "child"],
|
||||
nextIdById: new Map([
|
||||
["root", "root-copy"],
|
||||
["child", "child-copy"],
|
||||
]),
|
||||
clonedInstances: [
|
||||
[
|
||||
"root-copy",
|
||||
createInstance("root-copy", elementComponent, [
|
||||
{ type: "id", value: "child-copy" },
|
||||
{ type: "text", value: "Text" },
|
||||
]),
|
||||
],
|
||||
["child-copy", createInstance("child-copy", elementComponent)],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("find local style sources within instances", () => {
|
||||
const instanceIds = new Set(["instance2", "instance4"]);
|
||||
const styleSources = [
|
||||
|
||||
@@ -51,11 +51,7 @@ import {
|
||||
import { createRuntimeMutation } from "./mutation";
|
||||
import { findSerializedPageByInput, getSerializedPages } from "./pages";
|
||||
import { validatePageSelector } from "./page-selector";
|
||||
import {
|
||||
createPropClonePatches,
|
||||
createPropDeletePayload,
|
||||
createPropRenamePayload,
|
||||
} from "./props";
|
||||
import { createPropDeletePayload, createPropRenamePayload } from "./props";
|
||||
import {
|
||||
createTreeVariableRebindPayload,
|
||||
produceWebstudioDataMutation,
|
||||
@@ -78,10 +74,7 @@ import {
|
||||
type InstancePath,
|
||||
} from "./slot";
|
||||
import { getReparentDropTargetMutable, type DroppableTarget } from "./tree";
|
||||
import {
|
||||
createStyleClonePayload,
|
||||
serializeStyleDeclarations,
|
||||
} from "./style-utils";
|
||||
import { serializeStyleDeclarations } from "./style-utils";
|
||||
import { componentMetas } from "@webstudio-is/sdk-components-registry/metas";
|
||||
import { mapAttributeNames } from "@webstudio-is/content-engine/jsx-attributes";
|
||||
import { isMdxTemplateComponentName } from "@webstudio-is/content-engine/mdx";
|
||||
@@ -204,12 +197,6 @@ export const reparentInstanceInput = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const cloneInstanceInput = z.object({
|
||||
sourceInstanceId: z.string(),
|
||||
targetParentInstanceId: z.string().optional(),
|
||||
insertIndex: insertIndexInput.optional(),
|
||||
});
|
||||
|
||||
export const deleteInstancesInput = z.object({
|
||||
instanceIds: z.array(z.string()).min(1),
|
||||
templateNameConfirmation: blockTemplateNameConfirmationInput.optional(),
|
||||
@@ -936,33 +923,6 @@ export const replaceTextInput = z
|
||||
})
|
||||
.superRefine(validatePageSelector);
|
||||
|
||||
export const cloneInstanceSubtree = ({
|
||||
instances,
|
||||
rootInstanceId,
|
||||
createId,
|
||||
}: {
|
||||
instances: Map<Instance["id"], Instance>;
|
||||
rootInstanceId: Instance["id"];
|
||||
createId: () => Instance["id"];
|
||||
}) => {
|
||||
const instanceIds = collectInstanceIds(instances, rootInstanceId);
|
||||
const nextIdById = new Map(instanceIds.map((id) => [id, createId()]));
|
||||
const clonedInstances = instanceIds.flatMap((instanceId) => {
|
||||
const instance = instances.get(instanceId);
|
||||
const nextId = nextIdById.get(instanceId);
|
||||
if (instance === undefined || nextId === undefined) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
[
|
||||
nextId,
|
||||
cloneInstanceWithNewIds({ instance, newInstanceIds: nextIdById }),
|
||||
] satisfies [string, Instance],
|
||||
];
|
||||
});
|
||||
return { clonedInstances, instanceIds, nextIdById };
|
||||
};
|
||||
|
||||
export const findLocalStyleSourcesWithinInstances = (
|
||||
styleSources: Iterable<StyleSource>,
|
||||
styleSourceSelections: Iterable<StyleSourceSelection>,
|
||||
@@ -1576,90 +1536,6 @@ const remapTextTreeTemporaryIds = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const createInstanceClonePayload = ({
|
||||
instances,
|
||||
sourceInstanceId,
|
||||
targetParent,
|
||||
insertIndex,
|
||||
props,
|
||||
styleSourceSelections,
|
||||
styleSources,
|
||||
styles,
|
||||
createId,
|
||||
}: {
|
||||
instances: Map<Instance["id"], Instance>;
|
||||
sourceInstanceId: Instance["id"];
|
||||
targetParent: Instance;
|
||||
insertIndex: number;
|
||||
props: Iterable<Prop>;
|
||||
styleSourceSelections: Iterable<StyleSourceSelection>;
|
||||
styleSources: Iterable<StyleSource>;
|
||||
styles: Map<string, StyleDecl>;
|
||||
createId: () => string;
|
||||
}):
|
||||
| {
|
||||
clonedRootId: Instance["id"];
|
||||
clonedInstanceIds: Instance["id"][];
|
||||
payload: BuilderPatchChange[];
|
||||
}
|
||||
| undefined => {
|
||||
const { clonedInstances, nextIdById } = cloneInstanceSubtree({
|
||||
instances,
|
||||
rootInstanceId: sourceInstanceId,
|
||||
createId,
|
||||
});
|
||||
const clonedRootId = nextIdById.get(sourceInstanceId);
|
||||
if (clonedRootId === undefined) {
|
||||
return;
|
||||
}
|
||||
const instancesWithClones = new Map(instances);
|
||||
for (const [instanceId, instance] of clonedInstances) {
|
||||
instancesWithClones.set(instanceId, instance);
|
||||
}
|
||||
assignUniqueBlockTemplateNamesMutable({
|
||||
instanceIds: [clonedRootId],
|
||||
parent: targetParent,
|
||||
instances: instancesWithClones,
|
||||
components: componentMetas.keys(),
|
||||
});
|
||||
const propPatches = createPropClonePatches({
|
||||
nextIdById,
|
||||
props,
|
||||
createId,
|
||||
});
|
||||
return {
|
||||
clonedRootId,
|
||||
clonedInstanceIds: clonedInstances.map(([instanceId]) => instanceId),
|
||||
payload: compactBuilderPatchPayload([
|
||||
{
|
||||
namespace: "instances",
|
||||
patches: [
|
||||
...clonedInstances.map(([instanceId, instance]) => ({
|
||||
op: "add" as const,
|
||||
path: [instanceId],
|
||||
value: instance,
|
||||
})),
|
||||
{
|
||||
op: "add" as const,
|
||||
path: [targetParent.id, "children", insertIndex],
|
||||
value: createInstanceChild(clonedRootId),
|
||||
},
|
||||
],
|
||||
},
|
||||
...(propPatches.length === 0
|
||||
? []
|
||||
: [{ namespace: "props" as const, patches: propPatches }]),
|
||||
...createStyleClonePayload({
|
||||
styleSourceSelections,
|
||||
styleSources,
|
||||
styles,
|
||||
nextIdById,
|
||||
createId,
|
||||
}),
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
type TreeMutationState = Pick<
|
||||
BuilderState,
|
||||
| "instances"
|
||||
@@ -3578,64 +3454,6 @@ export const updateTextTree = (
|
||||
});
|
||||
};
|
||||
|
||||
export const cloneInstance = (
|
||||
state: TreeMutationState,
|
||||
input: z.infer<typeof cloneInstanceInput>,
|
||||
context: BuilderRuntimeContext
|
||||
) => {
|
||||
const mutationState = getRequiredTreeMutationState(state);
|
||||
const source = mutationState.instances.get(input.sourceInstanceId);
|
||||
if (source === undefined) {
|
||||
return throwBuilderRuntimeError("NOT_FOUND", "Instance not found");
|
||||
}
|
||||
const targetParent =
|
||||
input.targetParentInstanceId === undefined
|
||||
? findParentInstanceReference(mutationState.instances, source.id)
|
||||
?.instance
|
||||
: mutationState.instances.get(input.targetParentInstanceId);
|
||||
if (targetParent === undefined) {
|
||||
return throwBuilderRuntimeError("NOT_FOUND", "Instance not found");
|
||||
}
|
||||
const insertIndex = input.insertIndex ?? targetParent.children.length;
|
||||
if (insertIndex > targetParent.children.length) {
|
||||
return throwBuilderRuntimeError(
|
||||
"BAD_REQUEST",
|
||||
"Insert index is outside parent children"
|
||||
);
|
||||
}
|
||||
const clone = createInstanceClonePayload({
|
||||
instances: mutationState.instances,
|
||||
sourceInstanceId: source.id,
|
||||
targetParent,
|
||||
insertIndex,
|
||||
props: mutationState.props.values(),
|
||||
styleSourceSelections: mutationState.styleSourceSelections.values(),
|
||||
styleSources: mutationState.styleSources.values(),
|
||||
styles: mutationState.styles,
|
||||
createId: context.createId,
|
||||
});
|
||||
if (clone === undefined) {
|
||||
return throwBuilderRuntimeError(
|
||||
"BAD_REQUEST",
|
||||
"Source instance could not be cloned"
|
||||
);
|
||||
}
|
||||
return createRuntimeMutation({
|
||||
payload: clone.payload,
|
||||
result: {
|
||||
instanceId: clone.clonedRootId,
|
||||
instanceIds: clone.clonedInstanceIds,
|
||||
},
|
||||
invalidatesNamespaces: [
|
||||
"instances",
|
||||
"props",
|
||||
"styleSourceSelections",
|
||||
"styleSources",
|
||||
"styles",
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
export const serializeInstanceSummary = (
|
||||
instance: Instance,
|
||||
depth: number,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
clonePropForInstance,
|
||||
createPropDeletePayload,
|
||||
createPropBindingFromInput,
|
||||
createPropClonePatches,
|
||||
createStartingPropValueFromMeta,
|
||||
createPropRenamePayload,
|
||||
createPropUpsertPayload,
|
||||
@@ -454,33 +453,6 @@ describe("clonePropForInstance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPropClonePatches", () => {
|
||||
test("clones props for copied instances", () => {
|
||||
expect(
|
||||
createPropClonePatches({
|
||||
nextIdById: new Map([[image.id, "new-instance"]]),
|
||||
props: [
|
||||
prop("title", { value: "Title" }),
|
||||
prop("ignored", { instanceId: "other-instance" }),
|
||||
],
|
||||
createId: () => "new-prop",
|
||||
})
|
||||
).toEqual([
|
||||
{
|
||||
op: "add",
|
||||
path: ["new-prop"],
|
||||
value: {
|
||||
id: "new-prop",
|
||||
instanceId: "new-instance",
|
||||
name: "title",
|
||||
type: "string",
|
||||
value: "Title",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPropUpsertPayload", () => {
|
||||
test("creates add and replace patches", () => {
|
||||
const nextExisting = prop("src", { id: "incoming-src-id", value: "next" });
|
||||
|
||||
@@ -604,28 +604,6 @@ export const clonePropForInstance = ({
|
||||
instanceId,
|
||||
});
|
||||
|
||||
export const createPropClonePatches = ({
|
||||
nextIdById,
|
||||
props,
|
||||
createId,
|
||||
}: {
|
||||
nextIdById: Map<Instance["id"], Instance["id"]>;
|
||||
props: Iterable<Prop>;
|
||||
createId: () => Prop["id"];
|
||||
}) =>
|
||||
Array.from(props).flatMap((prop) => {
|
||||
const nextInstanceId = nextIdById.get(prop.instanceId);
|
||||
if (nextInstanceId === undefined) {
|
||||
return [];
|
||||
}
|
||||
const nextProp = clonePropForInstance({
|
||||
prop,
|
||||
propId: createId(),
|
||||
instanceId: nextInstanceId,
|
||||
});
|
||||
return [{ op: "add" as const, path: [nextProp.id], value: nextProp }];
|
||||
});
|
||||
|
||||
export const createPropUpsertPayload = ({
|
||||
props,
|
||||
nextProps,
|
||||
|
||||
@@ -1459,12 +1459,12 @@ export const builderRuntimeOperations = [
|
||||
"instances.clone",
|
||||
api("clone-instance", "cloneInstance"),
|
||||
mutationContract({
|
||||
readNamespaces: treeMutationNamespaces,
|
||||
writeNamespaces: treeMutationNamespaces,
|
||||
readNamespaces: instanceDuplicate.instanceDuplicateNamespaces,
|
||||
writeNamespaces: instanceDuplicate.instanceDuplicateNamespaces,
|
||||
}),
|
||||
instances.cloneInstanceInput,
|
||||
instanceDuplicate.cloneInstanceInput,
|
||||
({ state, input, context }) =>
|
||||
instances.cloneInstance(state, input, context)
|
||||
instanceDuplicate.cloneInstance(state, input, context)
|
||||
),
|
||||
runtimeOperation(
|
||||
"instances.duplicateAfterItself",
|
||||
|
||||
@@ -3,15 +3,12 @@ import type { StyleProperty, StyleValue } from "@webstudio-is/css-engine";
|
||||
import {
|
||||
getStyleDeclKey,
|
||||
type StyleDecl,
|
||||
type Styles,
|
||||
type StyleSource,
|
||||
type StyleSourceSelection,
|
||||
} from "@webstudio-is/sdk";
|
||||
import {
|
||||
collectFontFamiliesFromStyleDecls,
|
||||
collectFontFamiliesFromStyleValue,
|
||||
cloneStyles,
|
||||
createStyleClonePayload,
|
||||
getUniqueNameWithSuffix,
|
||||
isAutoGridPlacement,
|
||||
resetGridChildPlacement,
|
||||
@@ -32,115 +29,6 @@ test("creates a unique name after the highest numeric suffix", () => {
|
||||
).toBe("color-4");
|
||||
});
|
||||
|
||||
const createStyleDecl = (
|
||||
styleSourceId: string,
|
||||
breakpointId: string,
|
||||
value?: string
|
||||
): StyleDecl => {
|
||||
return {
|
||||
styleSourceId,
|
||||
breakpointId,
|
||||
property: "width",
|
||||
value: {
|
||||
type: "keyword",
|
||||
value: value ?? "value",
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createStyleDeclPair = (
|
||||
styleSourceId: string,
|
||||
breakpointId: string,
|
||||
state?: string,
|
||||
value?: string
|
||||
) => {
|
||||
return [
|
||||
getStyleDeclKey({
|
||||
styleSourceId,
|
||||
breakpointId,
|
||||
state,
|
||||
property: "width",
|
||||
}),
|
||||
createStyleDecl(styleSourceId, breakpointId, value),
|
||||
] as const;
|
||||
};
|
||||
|
||||
test("clone styles with applied new style source ids", () => {
|
||||
const styles: Styles = new Map([
|
||||
createStyleDeclPair("styleSource1", "bp1"),
|
||||
createStyleDeclPair("styleSource2", "bp2"),
|
||||
createStyleDeclPair("styleSource1", "bp3"),
|
||||
createStyleDeclPair("styleSource3", "bp4"),
|
||||
createStyleDeclPair("styleSource1", "bp5"),
|
||||
createStyleDeclPair("styleSource3", "bp6"),
|
||||
]);
|
||||
const clonedStyleSourceIds = new Map<StyleSource["id"], StyleSource["id"]>();
|
||||
clonedStyleSourceIds.set("styleSource2", "newStyleSource2");
|
||||
clonedStyleSourceIds.set("styleSource3", "newStyleSource3");
|
||||
expect(cloneStyles(styles, clonedStyleSourceIds)).toEqual([
|
||||
createStyleDecl("newStyleSource2", "bp2"),
|
||||
createStyleDecl("newStyleSource3", "bp4"),
|
||||
createStyleDecl("newStyleSource3", "bp6"),
|
||||
]);
|
||||
});
|
||||
|
||||
test("clones local styles while keeping design tokens shared", () => {
|
||||
const styleDecl: StyleDecl = {
|
||||
styleSourceId: "local-1",
|
||||
breakpointId: "base",
|
||||
property: "color",
|
||||
value: { type: "keyword", value: "red" },
|
||||
};
|
||||
const payload = createStyleClonePayload({
|
||||
styleSourceSelections: [
|
||||
{ instanceId: "source-1", values: ["token-1", "local-1"] },
|
||||
],
|
||||
styleSources: [
|
||||
{ type: "local", id: "local-1" },
|
||||
{ type: "token", id: "token-1", name: "Token" },
|
||||
],
|
||||
styles: new Map([[getStyleDeclKey(styleDecl), styleDecl]]),
|
||||
nextIdById: new Map([["source-1", "clone-1"]]),
|
||||
createId: () => "local-copy",
|
||||
});
|
||||
|
||||
expect(payload).toEqual([
|
||||
{
|
||||
namespace: "styleSources",
|
||||
patches: [
|
||||
{
|
||||
op: "add",
|
||||
path: ["local-copy"],
|
||||
value: { type: "local", id: "local-copy" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "styleSourceSelections",
|
||||
patches: [
|
||||
{
|
||||
op: "add",
|
||||
path: ["clone-1"],
|
||||
value: {
|
||||
instanceId: "clone-1",
|
||||
values: ["token-1", "local-copy"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
namespace: "styles",
|
||||
patches: [
|
||||
{
|
||||
op: "add",
|
||||
path: ["local-copy:base:color:"],
|
||||
value: { ...styleDecl, styleSourceId: "local-copy" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("serializes direct and token style declarations for selected instances", () => {
|
||||
const styles: StyleDecl[] = [
|
||||
{
|
||||
|
||||
@@ -8,8 +8,6 @@ import type {
|
||||
StyleSourceSelection,
|
||||
StyleSourceSelections,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { getStyleDeclKey } from "@webstudio-is/sdk";
|
||||
import type { BuilderPatchChange } from "../contracts/patch";
|
||||
|
||||
export const getUniqueNameWithSuffix = (
|
||||
name: string,
|
||||
@@ -268,135 +266,3 @@ export const serializeStyleDeclarations = ({
|
||||
}
|
||||
return declarations;
|
||||
};
|
||||
|
||||
export const cloneStyles = (
|
||||
styles: Styles,
|
||||
clonedStyleSourceIds: Map<StyleSource["id"], StyleSource["id"]>
|
||||
) => {
|
||||
const clonedStyles: StyleDecl[] = [];
|
||||
for (const styleDecl of styles.values()) {
|
||||
const styleSourceId = clonedStyleSourceIds.get(styleDecl.styleSourceId);
|
||||
if (styleSourceId === undefined) {
|
||||
continue;
|
||||
}
|
||||
clonedStyles.push({
|
||||
...styleDecl,
|
||||
styleSourceId,
|
||||
});
|
||||
}
|
||||
return clonedStyles;
|
||||
};
|
||||
|
||||
export const createLocalStyleSourceClonePlan = ({
|
||||
styleSourceSelections,
|
||||
styleSources,
|
||||
newInstanceIds,
|
||||
createId,
|
||||
}: {
|
||||
styleSourceSelections: Iterable<StyleSourceSelection>;
|
||||
styleSources: Iterable<StyleSource>;
|
||||
newInstanceIds: Map<Instance["id"], Instance["id"]>;
|
||||
createId: () => StyleSource["id"];
|
||||
}) => {
|
||||
const styleSourceById = new Map(
|
||||
Array.from(styleSources, (styleSource) => [styleSource.id, styleSource])
|
||||
);
|
||||
const newLocalStyleSourceIds = new Map<
|
||||
StyleSource["id"],
|
||||
StyleSource["id"]
|
||||
>();
|
||||
const getNextStyleSourceId = (styleSourceId: StyleSource["id"]) => {
|
||||
const styleSource = styleSourceById.get(styleSourceId);
|
||||
if (styleSource?.type !== "local") {
|
||||
return styleSourceId;
|
||||
}
|
||||
let nextStyleSourceId = newLocalStyleSourceIds.get(styleSourceId);
|
||||
if (nextStyleSourceId === undefined) {
|
||||
nextStyleSourceId = createId();
|
||||
newLocalStyleSourceIds.set(styleSourceId, nextStyleSourceId);
|
||||
}
|
||||
return nextStyleSourceId;
|
||||
};
|
||||
|
||||
const selections: StyleSourceSelection[] = [];
|
||||
for (const selection of styleSourceSelections) {
|
||||
const nextInstanceId = newInstanceIds.get(selection.instanceId);
|
||||
if (nextInstanceId === undefined) {
|
||||
continue;
|
||||
}
|
||||
selections.push({
|
||||
...selection,
|
||||
instanceId: nextInstanceId,
|
||||
values: selection.values.map(getNextStyleSourceId),
|
||||
});
|
||||
}
|
||||
|
||||
const localStyleSources: StyleSource[] = [];
|
||||
for (const [styleSourceId, nextStyleSourceId] of newLocalStyleSourceIds) {
|
||||
const styleSource = styleSourceById.get(styleSourceId);
|
||||
if (styleSource !== undefined) {
|
||||
localStyleSources.push({
|
||||
...styleSource,
|
||||
id: nextStyleSourceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
localStyleSources,
|
||||
localStyleSourceIds: newLocalStyleSourceIds,
|
||||
selections,
|
||||
};
|
||||
};
|
||||
|
||||
export const createStyleClonePayload = ({
|
||||
styleSourceSelections,
|
||||
styleSources,
|
||||
styles,
|
||||
nextIdById,
|
||||
createId,
|
||||
}: {
|
||||
styleSourceSelections: Iterable<StyleSourceSelection>;
|
||||
styleSources: Iterable<StyleSource>;
|
||||
styles: Styles;
|
||||
nextIdById: Map<Instance["id"], Instance["id"]>;
|
||||
createId: () => StyleSource["id"];
|
||||
}): BuilderPatchChange[] => {
|
||||
const clonePlan = createLocalStyleSourceClonePlan({
|
||||
styleSourceSelections,
|
||||
styleSources,
|
||||
newInstanceIds: nextIdById,
|
||||
createId,
|
||||
});
|
||||
const changes: BuilderPatchChange[] = [
|
||||
{
|
||||
namespace: "styleSources",
|
||||
patches: clonePlan.localStyleSources.map((styleSource) => ({
|
||||
op: "add" as const,
|
||||
path: [styleSource.id],
|
||||
value: styleSource,
|
||||
})),
|
||||
},
|
||||
{
|
||||
namespace: "styleSourceSelections",
|
||||
patches: clonePlan.selections.map((selection) => ({
|
||||
op: "add" as const,
|
||||
path: [selection.instanceId],
|
||||
value: selection,
|
||||
})),
|
||||
},
|
||||
{
|
||||
namespace: "styles",
|
||||
patches: cloneStyles(styles, clonePlan.localStyleSourceIds).map(
|
||||
(styleDecl) => ({
|
||||
op: "add" as const,
|
||||
path: [getStyleDeclKey(styleDecl)],
|
||||
value: styleDecl,
|
||||
})
|
||||
),
|
||||
},
|
||||
];
|
||||
return changes.flatMap((change) =>
|
||||
change.patches.length === 0 ? [] : [change]
|
||||
);
|
||||
};
|
||||
@@ -79,7 +79,6 @@ import {
|
||||
validateStyleSourceName,
|
||||
updateVarReferencesInProps,
|
||||
} from "./styles";
|
||||
import { createLocalStyleSourceClonePlan } from "./style-utils";
|
||||
import { getInputSchemaMetadata } from "../contracts/input-schema";
|
||||
import { getZodValidationIssues } from "./errors";
|
||||
import {
|
||||
@@ -3490,60 +3489,6 @@ describe("addStyleSourceToInstanceMutable", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLocalStyleSourceClonePlan", () => {
|
||||
test("clones local style sources and remaps copied selections", () => {
|
||||
const plan = createLocalStyleSourceClonePlan({
|
||||
styleSourceSelections: [
|
||||
{ instanceId: "source", values: ["token", "local"] },
|
||||
{ instanceId: "ignored", values: ["local"] },
|
||||
],
|
||||
styleSources: [
|
||||
{ id: "token", type: "token", name: "Token" },
|
||||
local("local"),
|
||||
],
|
||||
newInstanceIds: new Map([["source", "copy"]]),
|
||||
createId: () => "local-copy",
|
||||
});
|
||||
|
||||
expect(plan.localStyleSourceIds).toEqual(
|
||||
new Map([["local", "local-copy"]])
|
||||
);
|
||||
expect(plan.localStyleSources).toEqual([
|
||||
{ id: "local-copy", type: "local" },
|
||||
]);
|
||||
expect(plan.selections).toEqual([
|
||||
{ instanceId: "copy", values: ["token", "local-copy"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reuses a cloned local style source across copied selections", () => {
|
||||
let idIndex = 0;
|
||||
const plan = createLocalStyleSourceClonePlan({
|
||||
styleSourceSelections: [
|
||||
{ instanceId: "source-1", values: ["local"] },
|
||||
{ instanceId: "source-2", values: ["local"] },
|
||||
],
|
||||
styleSources: [local("local")],
|
||||
newInstanceIds: new Map([
|
||||
["source-1", "copy-1"],
|
||||
["source-2", "copy-2"],
|
||||
]),
|
||||
createId: () => `local-copy-${idIndex++}`,
|
||||
});
|
||||
|
||||
expect(plan.localStyleSourceIds).toEqual(
|
||||
new Map([["local", "local-copy-0"]])
|
||||
);
|
||||
expect(plan.localStyleSources).toEqual([
|
||||
{ id: "local-copy-0", type: "local" },
|
||||
]);
|
||||
expect(plan.selections).toEqual([
|
||||
{ instanceId: "copy-1", values: ["local-copy-0"] },
|
||||
{ instanceId: "copy-2", values: ["local-copy-0"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createLocalStyleSourcePatchPlan", () => {
|
||||
test("returns existing local style source", () => {
|
||||
const styleSources = sources([local("local")]);
|
||||
|
||||
+5
@@ -188,6 +188,11 @@ export const serverOnlyRouterOperationMetadata = {
|
||||
minLength: 1,
|
||||
maxLength: 100,
|
||||
},
|
||||
serverApiContractVersion: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
maxLength: 100,
|
||||
},
|
||||
bundleVersion: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
|
||||
@@ -61,6 +61,16 @@ const report = {
|
||||
} as const;
|
||||
|
||||
describe("issue report contract", () => {
|
||||
test("preserves the negotiated server contract alongside the CLI contract", () => {
|
||||
const runtime = {
|
||||
...report.runtime,
|
||||
serverApiContractVersion: "public-api:server",
|
||||
};
|
||||
expect(issueReportInput.parse({ ...report, runtime }).runtime).toEqual(
|
||||
runtime
|
||||
);
|
||||
});
|
||||
|
||||
test("advertised entity ID fields enforce the privacy rule", () => {
|
||||
const schema = z.toJSONSchema(issueReportEntityIdField);
|
||||
expect(schema.enum).toContain("pageId");
|
||||
|
||||
@@ -146,6 +146,7 @@ const issueReportRuntime = z
|
||||
architecture: z.string().trim().min(1).max(100),
|
||||
executionMode: z.enum(["mcp"]),
|
||||
apiContractVersion: z.string().trim().min(1).max(100),
|
||||
serverApiContractVersion: z.string().trim().min(1).max(100).optional(),
|
||||
bundleVersion: z.string().trim().min(1).max(100).optional(),
|
||||
projectId: issueReportId.optional(),
|
||||
recentFailure: issueReportRecentFailureSchema.optional(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cleanup, render, waitFor } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { ReactSdkContext } from "@webstudio-is/react-sdk/runtime";
|
||||
import { YouTube } from "./youtube";
|
||||
import { VimeoPreviewImage } from "./vimeo-preview-image";
|
||||
|
||||
const sdkContext = {
|
||||
assetBaseUrl: "/assets/",
|
||||
@@ -66,3 +67,46 @@ test("preconnect is consent-aware and only warms the player", async () => {
|
||||
);
|
||||
expect(getPreconnectOrigins()).not.toContain("https://img.youtube.com");
|
||||
});
|
||||
|
||||
test("updates the preview image when the video URL changes", async () => {
|
||||
const firstId = "dQw4w9WgXcQ";
|
||||
const secondId = "jNQXAC9IVRw";
|
||||
const props = {
|
||||
showPreview: true,
|
||||
children: <VimeoPreviewImage alt="Video preview" />,
|
||||
};
|
||||
const { getByAltText, rerender } = render(
|
||||
renderYouTube({
|
||||
...props,
|
||||
url: `https://www.youtube.com/watch?v=${firstId}`,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByAltText("Video preview").getAttribute("src")).toContain(firstId)
|
||||
);
|
||||
|
||||
rerender(
|
||||
renderYouTube({
|
||||
...props,
|
||||
url: `https://www.youtube.com/watch?v=${secondId}`,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByAltText("Video preview").getAttribute("src")).toContain(
|
||||
secondId
|
||||
)
|
||||
);
|
||||
|
||||
rerender(
|
||||
renderYouTube({
|
||||
...props,
|
||||
url: `https://www.youtube.com/watch?v=${firstId}`,
|
||||
})
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByAltText("Video preview").getAttribute("src")).toContain(firstId)
|
||||
);
|
||||
});
|
||||
@@ -404,12 +404,15 @@ const Player = ({
|
||||
useEffect(() => {
|
||||
const videoId = getVideoId(videoUrl);
|
||||
if (!videoId || !showPreview) {
|
||||
onPreviewImageUrlChange(undefined);
|
||||
if (previewImageUrl !== undefined) {
|
||||
onPreviewImageUrlChange(undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!previewImageUrl) {
|
||||
onPreviewImageUrlChange(getPreviewImageUrl(videoId));
|
||||
const nextPreviewImageUrl = getPreviewImageUrl(videoId);
|
||||
if (previewImageUrl?.href !== nextPreviewImageUrl.href) {
|
||||
onPreviewImageUrlChange(nextPreviewImageUrl);
|
||||
}
|
||||
}, [onPreviewImageUrlChange, showPreview, videoUrl, previewImageUrl]);
|
||||
|
||||
|
||||
Reference in new issue
Block a user