diff --git a/apps/builder/app/builder/shared/commands.test.tsx b/apps/builder/app/builder/shared/commands.test.tsx index 4ba7965a5467..c1d132daa60a 100644 --- a/apps/builder/app/builder/shared/commands.test.tsx +++ b/apps/builder/app/builder/shared/commands.test.tsx @@ -1,20 +1,27 @@ import { describe, expect, test } from "vitest"; +import { coreMetas } from "@webstudio-is/sdk"; import * as baseMetas from "@webstudio-is/sdk-components-react/metas"; import { createDefaultPages } from "@webstudio-is/project-build"; import { $, renderData, ws } from "@webstudio-is/template"; import { $instances, $pages, + $props, $registeredComponentMetas, } from "~/shared/nano-states"; import { registerContainers } from "~/shared/sync"; import { $awareness, selectInstance } from "~/shared/awareness"; -import { deleteSelectedInstance, unwrap, wrapIn } from "./commands"; +import { + deleteSelectedInstance, + replaceWith, + unwrap, + wrapIn, +} from "./commands"; import { elementComponent } from "@webstudio-is/sdk"; registerContainers(); -const metas = new Map(Object.entries(baseMetas)); +const metas = new Map(Object.entries({ ...coreMetas, ...baseMetas })); $registeredComponentMetas.set(metas); $pages.set(createDefaultPages({ rootInstanceId: "" })); $awareness.set({ pageId: "" }); @@ -167,6 +174,117 @@ describe("wrap in", () => { }); }); +describe("replace with", () => { + test("replace legacy tag with element", () => { + const { instances, props } = renderData( + + <$.Box tag="article" ws:id="articleId"> + + ); + $instances.set(instances); + $props.set(props); + selectInstance(["articleId", "bodyId"]); + replaceWith(elementComponent); + const { instances: newInstances, props: newProps } = renderData( + + + + ); + expect({ instances: $instances.get(), props: $props.get() }).toEqual({ + instances: newInstances, + props: newProps, + }); + }); + + test("migrate legacy properties as well", () => { + const { instances, props } = renderData( + + <$.Box + ws:tag="div" + ws:id="divId" + className="my-class" + htmlFor="my-id" + > + + ); + $instances.set(instances); + $props.set(props); + selectInstance(["divId", "bodyId"]); + replaceWith(elementComponent); + const { instances: newInstances, props: newProps } = renderData( + + + + ); + expect({ instances: $instances.get(), props: $props.get() }).toEqual({ + instances: newInstances, + props: newProps, + }); + }); + + test("preserve currently specified tag", () => { + $instances.set( + renderData( + + <$.Box ws:tag="article" ws:id="articleId"> + + ).instances + ); + selectInstance(["articleId", "bodyId"]); + replaceWith(elementComponent); + expect($instances.get()).toEqual( + renderData( + + + + ).instances + ); + }); + + test("replace with first tag from presets", () => { + $instances.set( + renderData( + + <$.Heading ws:id="headingId"> + + ).instances + ); + selectInstance(["headingId", "bodyId"]); + replaceWith(elementComponent); + expect($instances.get()).toEqual( + renderData( + + + + ).instances + ); + }); + + test("fallback to div", () => { + $instances.set( + renderData( + + <$.Box ws:id="divId"> + + ).instances + ); + selectInstance(["divId", "bodyId"]); + replaceWith(elementComponent); + expect($instances.get()).toEqual( + renderData( + + + + ).instances + ); + }); +}); + describe("unwrap", () => { test("unwrap instance", () => { $instances.set( diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 1a23a906ec80..ada4d6139988 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -55,6 +55,8 @@ import { generateFragmentFromHtml } from "~/shared/html"; import { generateFragmentFromTailwind } from "~/shared/tailwind/tailwind"; import { denormalizeSrcProps } from "~/shared/copy-paste/asset-upload"; import { getInstanceLabel } from "./instance-label"; +import { $instanceTags } from "../features/style-panel/shared/model"; +import { reactPropsToStandardAttributes } from "@webstudio-is/react-sdk"; export const $styleSourceInputElement = atom(); @@ -174,7 +176,7 @@ export const wrapIn = (component: string, tag?: string) => { component, children: [{ type: "id", value: selectedInstance.id }], }; - if (tag || elementComponent) { + if (tag || component === elementComponent) { newInstance.tag = tag ?? "div"; } const parentInstance = data.instances.get(parentItem.instance.id); @@ -204,6 +206,61 @@ export const wrapIn = (component: string, tag?: string) => { } }; +export const replaceWith = (component: string, tag?: string) => { + const instancePath = $selectedInstancePath.get(); + // global root or body are selected + if (instancePath === undefined || instancePath.length === 1) { + return; + } + const [selectedItem] = instancePath; + const selectedInstance = selectedItem.instance; + const selectedInstanceSelector = selectedItem.instanceSelector; + const metas = $registeredComponentMetas.get(); + const instanceTags = $instanceTags.get(); + try { + updateWebstudioData((data) => { + const instance = data.instances.get(selectedInstance.id); + if (instance === undefined) { + return; + } + instance.component = component; + // replace with specified tag or with currently used + if (tag || component === elementComponent) { + instance.tag = tag ?? instanceTags.get(selectedInstance.id) ?? "div"; + // delete legacy tag prop if specified + for (const prop of data.props.values()) { + if (prop.instanceId !== selectedInstance.id) { + continue; + } + if (prop.name === "tag") { + data.props.delete(prop.id); + continue; + } + const newName = reactPropsToStandardAttributes[prop.name]; + if (newName) { + const newId = `${prop.instanceId}:${newName}`; + data.props.delete(prop.id); + data.props.set(newId, { ...prop, id: newId, name: newName }); + } + } + } + const isSatisfying = isTreeSatisfyingContentModel({ + instances: data.instances, + props: data.props, + metas, + instanceSelector: selectedInstanceSelector, + }); + if (isSatisfying === false) { + const label = getInstanceLabel({ component, tag }, {}); + toast.error(`Cannot replace with ${label}`); + throw Error("Abort transaction"); + } + }); + } catch { + // do nothing + } +}; + export const unwrap = () => { const instancePath = $selectedInstancePath.get(); // global root or body are selected @@ -529,6 +586,14 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ name: "unwrap", handler: () => unwrap(), }, + { + name: "replaceWithElement", + handler: () => replaceWith(elementComponent), + }, + { + name: "replaceWithLink", + handler: () => replaceWith(elementComponent, "a"), + }, ...(isFeatureEnabled("tailwind") ? [