Skip to content

Commit 14e6ced

Browse files
authored
feat: add replace with element command (#5242)
Ref #3632 This can be a nice addition and a way to migrate legacy components. https://github.com/user-attachments/assets/8354d4bd-e60b-4cf3-b662-5c8e974d9537
1 parent 24180a2 commit 14e6ced

File tree

2 files changed

+186
-3
lines changed

2 files changed

+186
-3
lines changed

apps/builder/app/builder/shared/commands.test.tsx

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
import { describe, expect, test } from "vitest";
2+
import { coreMetas } from "@webstudio-is/sdk";
23
import * as baseMetas from "@webstudio-is/sdk-components-react/metas";
34
import { createDefaultPages } from "@webstudio-is/project-build";
45
import { $, renderData, ws } from "@webstudio-is/template";
56
import {
67
$instances,
78
$pages,
9+
$props,
810
$registeredComponentMetas,
911
} from "~/shared/nano-states";
1012
import { registerContainers } from "~/shared/sync";
1113
import { $awareness, selectInstance } from "~/shared/awareness";
12-
import { deleteSelectedInstance, unwrap, wrapIn } from "./commands";
14+
import {
15+
deleteSelectedInstance,
16+
replaceWith,
17+
unwrap,
18+
wrapIn,
19+
} from "./commands";
1320
import { elementComponent } from "@webstudio-is/sdk";
1421

1522
registerContainers();
1623

17-
const metas = new Map(Object.entries(baseMetas));
24+
const metas = new Map(Object.entries({ ...coreMetas, ...baseMetas }));
1825
$registeredComponentMetas.set(metas);
1926
$pages.set(createDefaultPages({ rootInstanceId: "" }));
2027
$awareness.set({ pageId: "" });
@@ -167,6 +174,117 @@ describe("wrap in", () => {
167174
});
168175
});
169176

177+
describe("replace with", () => {
178+
test("replace legacy tag with element", () => {
179+
const { instances, props } = renderData(
180+
<ws.element ws:tag="body" ws:id="bodyId">
181+
<$.Box tag="article" ws:id="articleId"></$.Box>
182+
</ws.element>
183+
);
184+
$instances.set(instances);
185+
$props.set(props);
186+
selectInstance(["articleId", "bodyId"]);
187+
replaceWith(elementComponent);
188+
const { instances: newInstances, props: newProps } = renderData(
189+
<ws.element ws:tag="body" ws:id="bodyId">
190+
<ws.element ws:tag="article" ws:id="articleId"></ws.element>
191+
</ws.element>
192+
);
193+
expect({ instances: $instances.get(), props: $props.get() }).toEqual({
194+
instances: newInstances,
195+
props: newProps,
196+
});
197+
});
198+
199+
test("migrate legacy properties as well", () => {
200+
const { instances, props } = renderData(
201+
<ws.element ws:tag="body" ws:id="bodyId">
202+
<$.Box
203+
ws:tag="div"
204+
ws:id="divId"
205+
className="my-class"
206+
htmlFor="my-id"
207+
></$.Box>
208+
</ws.element>
209+
);
210+
$instances.set(instances);
211+
$props.set(props);
212+
selectInstance(["divId", "bodyId"]);
213+
replaceWith(elementComponent);
214+
const { instances: newInstances, props: newProps } = renderData(
215+
<ws.element ws:tag="body" ws:id="bodyId">
216+
<ws.element
217+
ws:tag="div"
218+
ws:id="divId"
219+
class="my-class"
220+
for="my-id"
221+
></ws.element>
222+
</ws.element>
223+
);
224+
expect({ instances: $instances.get(), props: $props.get() }).toEqual({
225+
instances: newInstances,
226+
props: newProps,
227+
});
228+
});
229+
230+
test("preserve currently specified tag", () => {
231+
$instances.set(
232+
renderData(
233+
<ws.element ws:tag="body" ws:id="bodyId">
234+
<$.Box ws:tag="article" ws:id="articleId"></$.Box>
235+
</ws.element>
236+
).instances
237+
);
238+
selectInstance(["articleId", "bodyId"]);
239+
replaceWith(elementComponent);
240+
expect($instances.get()).toEqual(
241+
renderData(
242+
<ws.element ws:tag="body" ws:id="bodyId">
243+
<ws.element ws:tag="article" ws:id="articleId"></ws.element>
244+
</ws.element>
245+
).instances
246+
);
247+
});
248+
249+
test("replace with first tag from presets", () => {
250+
$instances.set(
251+
renderData(
252+
<ws.element ws:tag="body" ws:id="bodyId">
253+
<$.Heading ws:id="headingId"></$.Heading>
254+
</ws.element>
255+
).instances
256+
);
257+
selectInstance(["headingId", "bodyId"]);
258+
replaceWith(elementComponent);
259+
expect($instances.get()).toEqual(
260+
renderData(
261+
<ws.element ws:tag="body" ws:id="bodyId">
262+
<ws.element ws:tag="h1" ws:id="headingId"></ws.element>
263+
</ws.element>
264+
).instances
265+
);
266+
});
267+
268+
test("fallback to div", () => {
269+
$instances.set(
270+
renderData(
271+
<ws.element ws:tag="body" ws:id="bodyId">
272+
<$.Box ws:id="divId"></$.Box>
273+
</ws.element>
274+
).instances
275+
);
276+
selectInstance(["divId", "bodyId"]);
277+
replaceWith(elementComponent);
278+
expect($instances.get()).toEqual(
279+
renderData(
280+
<ws.element ws:tag="body" ws:id="bodyId">
281+
<ws.element ws:tag="div" ws:id="divId"></ws.element>
282+
</ws.element>
283+
).instances
284+
);
285+
});
286+
});
287+
170288
describe("unwrap", () => {
171289
test("unwrap instance", () => {
172290
$instances.set(

apps/builder/app/builder/shared/commands.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ import { generateFragmentFromHtml } from "~/shared/html";
5555
import { generateFragmentFromTailwind } from "~/shared/tailwind/tailwind";
5656
import { denormalizeSrcProps } from "~/shared/copy-paste/asset-upload";
5757
import { getInstanceLabel } from "./instance-label";
58+
import { $instanceTags } from "../features/style-panel/shared/model";
59+
import { reactPropsToStandardAttributes } from "@webstudio-is/react-sdk";
5860

5961
export const $styleSourceInputElement = atom<HTMLInputElement | undefined>();
6062

@@ -174,7 +176,7 @@ export const wrapIn = (component: string, tag?: string) => {
174176
component,
175177
children: [{ type: "id", value: selectedInstance.id }],
176178
};
177-
if (tag || elementComponent) {
179+
if (tag || component === elementComponent) {
178180
newInstance.tag = tag ?? "div";
179181
}
180182
const parentInstance = data.instances.get(parentItem.instance.id);
@@ -204,6 +206,61 @@ export const wrapIn = (component: string, tag?: string) => {
204206
}
205207
};
206208

209+
export const replaceWith = (component: string, tag?: string) => {
210+
const instancePath = $selectedInstancePath.get();
211+
// global root or body are selected
212+
if (instancePath === undefined || instancePath.length === 1) {
213+
return;
214+
}
215+
const [selectedItem] = instancePath;
216+
const selectedInstance = selectedItem.instance;
217+
const selectedInstanceSelector = selectedItem.instanceSelector;
218+
const metas = $registeredComponentMetas.get();
219+
const instanceTags = $instanceTags.get();
220+
try {
221+
updateWebstudioData((data) => {
222+
const instance = data.instances.get(selectedInstance.id);
223+
if (instance === undefined) {
224+
return;
225+
}
226+
instance.component = component;
227+
// replace with specified tag or with currently used
228+
if (tag || component === elementComponent) {
229+
instance.tag = tag ?? instanceTags.get(selectedInstance.id) ?? "div";
230+
// delete legacy tag prop if specified
231+
for (const prop of data.props.values()) {
232+
if (prop.instanceId !== selectedInstance.id) {
233+
continue;
234+
}
235+
if (prop.name === "tag") {
236+
data.props.delete(prop.id);
237+
continue;
238+
}
239+
const newName = reactPropsToStandardAttributes[prop.name];
240+
if (newName) {
241+
const newId = `${prop.instanceId}:${newName}`;
242+
data.props.delete(prop.id);
243+
data.props.set(newId, { ...prop, id: newId, name: newName });
244+
}
245+
}
246+
}
247+
const isSatisfying = isTreeSatisfyingContentModel({
248+
instances: data.instances,
249+
props: data.props,
250+
metas,
251+
instanceSelector: selectedInstanceSelector,
252+
});
253+
if (isSatisfying === false) {
254+
const label = getInstanceLabel({ component, tag }, {});
255+
toast.error(`Cannot replace with ${label}`);
256+
throw Error("Abort transaction");
257+
}
258+
});
259+
} catch {
260+
// do nothing
261+
}
262+
};
263+
207264
export const unwrap = () => {
208265
const instancePath = $selectedInstancePath.get();
209266
// global root or body are selected
@@ -529,6 +586,14 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({
529586
name: "unwrap",
530587
handler: () => unwrap(),
531588
},
589+
{
590+
name: "replaceWithElement",
591+
handler: () => replaceWith(elementComponent),
592+
},
593+
{
594+
name: "replaceWithLink",
595+
handler: () => replaceWith(elementComponent, "a"),
596+
},
532597

533598
...(isFeatureEnabled("tailwind")
534599
? [

0 commit comments

Comments
 (0)