Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 120 additions & 2 deletions apps/builder/app/builder/shared/commands.test.tsx
Original file line number Diff line number Diff line change
@@ -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: "" });
Expand Down Expand Up @@ -167,6 +174,117 @@ describe("wrap in", () => {
});
});

describe("replace with", () => {
test("replace legacy tag with element", () => {
const { instances, props } = renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.Box tag="article" ws:id="articleId"></$.Box>
</ws.element>
);
$instances.set(instances);
$props.set(props);
selectInstance(["articleId", "bodyId"]);
replaceWith(elementComponent);
const { instances: newInstances, props: newProps } = renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<ws.element ws:tag="article" ws:id="articleId"></ws.element>
</ws.element>
);
expect({ instances: $instances.get(), props: $props.get() }).toEqual({
instances: newInstances,
props: newProps,
});
});

test("migrate legacy properties as well", () => {
const { instances, props } = renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.Box
ws:tag="div"
ws:id="divId"
className="my-class"
htmlFor="my-id"
></$.Box>
</ws.element>
);
$instances.set(instances);
$props.set(props);
selectInstance(["divId", "bodyId"]);
replaceWith(elementComponent);
const { instances: newInstances, props: newProps } = renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<ws.element
ws:tag="div"
ws:id="divId"
class="my-class"
for="my-id"
></ws.element>
</ws.element>
);
expect({ instances: $instances.get(), props: $props.get() }).toEqual({
instances: newInstances,
props: newProps,
});
});

test("preserve currently specified tag", () => {
$instances.set(
renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.Box ws:tag="article" ws:id="articleId"></$.Box>
</ws.element>
).instances
);
selectInstance(["articleId", "bodyId"]);
replaceWith(elementComponent);
expect($instances.get()).toEqual(
renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<ws.element ws:tag="article" ws:id="articleId"></ws.element>
</ws.element>
).instances
);
});

test("replace with first tag from presets", () => {
$instances.set(
renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.Heading ws:id="headingId"></$.Heading>
</ws.element>
).instances
);
selectInstance(["headingId", "bodyId"]);
replaceWith(elementComponent);
expect($instances.get()).toEqual(
renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<ws.element ws:tag="h1" ws:id="headingId"></ws.element>
</ws.element>
).instances
);
});

test("fallback to div", () => {
$instances.set(
renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.Box ws:id="divId"></$.Box>
</ws.element>
).instances
);
selectInstance(["divId", "bodyId"]);
replaceWith(elementComponent);
expect($instances.get()).toEqual(
renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<ws.element ws:tag="div" ws:id="divId"></ws.element>
</ws.element>
).instances
);
});
});

describe("unwrap", () => {
test("unwrap instance", () => {
$instances.set(
Expand Down
67 changes: 66 additions & 1 deletion apps/builder/app/builder/shared/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement | undefined>();

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
? [
Expand Down