diff --git a/apps/builder/app/builder/features/ai/apply-operations.ts b/apps/builder/app/builder/features/ai/apply-operations.ts index 0db3f76145b5..0b46a30e85c2 100644 --- a/apps/builder/app/builder/features/ai/apply-operations.ts +++ b/apps/builder/app/builder/features/ai/apply-operations.ts @@ -20,7 +20,7 @@ import { $styles, } from "~/shared/nano-states"; import type { InstanceSelector } from "~/shared/tree-utils"; -import { $selectedInstance } from "~/shared/awareness"; +import { $selectedInstance, getInstancePath } from "~/shared/awareness"; import { isInstanceDetachable } from "~/shared/matcher"; export const applyOperations = (operations: operations.WsOperations) => { @@ -107,7 +107,10 @@ const deleteInstanceByOp = ( ) { return; } - deleteInstanceMutable(data, instanceSelector); + deleteInstanceMutable( + data, + getInstancePath(instanceSelector, data.instances) + ); }); } }; diff --git a/apps/builder/app/builder/features/pages/page-utils.ts b/apps/builder/app/builder/features/pages/page-utils.ts index fc1e0f58d59b..8a825c71c408 100644 --- a/apps/builder/app/builder/features/pages/page-utils.ts +++ b/apps/builder/app/builder/features/pages/page-utils.ts @@ -23,7 +23,12 @@ import { $variableValuesByInstanceSelector, } from "~/shared/nano-states"; import { insertPageCopyMutable } from "~/shared/page-utils"; -import { $selectedPage, getInstanceKey, selectPage } from "~/shared/awareness"; +import { + $selectedPage, + getInstanceKey, + getInstancePath, + selectPage, +} from "~/shared/awareness"; /** * When page or folder needs to be deleted or moved to a different parent, @@ -209,7 +214,10 @@ export const deletePageMutable = (pageId: Page["id"], data: WebstudioData) => { } const rootInstanceId = findPageByIdOrPath(pageId, pages)?.rootInstanceId; if (rootInstanceId !== undefined) { - deleteInstanceMutable(data, [rootInstanceId]); + deleteInstanceMutable( + data, + getInstancePath([rootInstanceId], data.instances) + ); } removeByMutable(pages.pages, (page) => page.id === pageId); cleanupChildRefsMutable(pageId, pages.folders); diff --git a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx index 66f797bf786e..dec4f12a128d 100644 --- a/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx +++ b/apps/builder/app/builder/features/workspace/canvas-tools/outline/block-instance-outline.tsx @@ -48,6 +48,7 @@ import { skipInertHandlersAttribute } from "~/builder/shared/inert-handlers"; import { insertTemplateAt } from "./block-utils"; import { useEffectEvent } from "~/shared/hook-utils/effect-event"; +import { getInstancePath } from "~/shared/awareness"; export const TemplatesMenu = ({ onOpenChange, @@ -382,7 +383,10 @@ export const BlockChildHoveredInstanceOutline = () => { } updateWebstudioData((data) => { - deleteInstanceMutable(data, outline.selector); + deleteInstanceMutable( + data, + getInstancePath(outline.selector, data.instances) + ); }); setButtonOutline(undefined); diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 829489ebfac2..346c7a3c23ab 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -133,7 +133,7 @@ export const deleteSelectedInstance = () => { newSelectedInstanceSelector = parentInstanceSelector; } updateWebstudioData((data) => { - if (deleteInstanceMutable(data, selectedInstanceSelector)) { + if (deleteInstanceMutable(data, instancePath)) { selectInstance(newSelectedInstanceSelector); } }); diff --git a/apps/builder/app/canvas/features/text-editor/text-editor.tsx b/apps/builder/app/canvas/features/text-editor/text-editor.tsx index 9c3a3a73c6e6..5bed9ea23988 100644 --- a/apps/builder/app/canvas/features/text-editor/text-editor.tsx +++ b/apps/builder/app/canvas/features/text-editor/text-editor.tsx @@ -89,6 +89,7 @@ import { setDataCollapsed } from "~/canvas/collapsed"; import { $selectedPage, addTemporaryInstance, + getInstancePath, selectInstance, } from "~/shared/awareness"; import { shallowEqual } from "shallow-equal"; @@ -1044,7 +1045,10 @@ const RichTextContentPluginInternal = ({ if (blockChildSelector) { updateWebstudioData((data) => { - deleteInstanceMutable(data, rootInstanceSelector); + deleteInstanceMutable( + data, + getInstancePath(rootInstanceSelector, data.instances) + ); }); } } @@ -1113,7 +1117,10 @@ const RichTextContentPluginInternal = ({ updateWebstudioData((data) => { deleteInstanceMutable( data, - isLastChild ? parentInstanceSelector : rootInstanceSelector + getInstancePath( + isLastChild ? parentInstanceSelector : rootInstanceSelector, + data.instances + ) ); }); @@ -1128,7 +1135,10 @@ const RichTextContentPluginInternal = ({ onNext(editor.getEditorState(), { reason: "left" }); updateWebstudioData((data) => { - deleteInstanceMutable(data, blockChildSelector); + deleteInstanceMutable( + data, + getInstancePath(blockChildSelector, data.instances) + ); }); event.preventDefault(); @@ -1218,7 +1228,12 @@ const RichTextContentPluginInternal = ({ updateWebstudioData((data) => { deleteInstanceMutable( data, - isLastChild ? parentInstanceSelector : rootInstanceSelector + getInstancePath( + isLastChild + ? parentInstanceSelector + : rootInstanceSelector, + data.instances + ) ); }); } diff --git a/apps/builder/app/shared/awareness.ts b/apps/builder/app/shared/awareness.ts index cabac0981a6a..0303c18245f6 100644 --- a/apps/builder/app/shared/awareness.ts +++ b/apps/builder/app/shared/awareness.ts @@ -81,19 +81,19 @@ export type InstancePath = Array<{ instanceSelector: string[]; }>; -const getInstancePath = ( +export const getInstancePath = ( + instanceSelector: string[], instances: Instances, - virtualInstances: Instances, - temporaryInstances: Instances, - instanceSelector: string[] + virtualInstances?: Instances, + temporaryInstances?: Instances ): InstancePath => { const instancePath: InstancePath = []; for (let index = 0; index < instanceSelector.length; index += 1) { const instanceId = instanceSelector[index]; const instance = instances.get(instanceId) ?? - virtualInstances.get(instanceId) ?? - temporaryInstances.get(instanceId); + virtualInstances?.get(instanceId) ?? + temporaryInstances?.get(instanceId); // collection item can be undefined if (instance === undefined) { continue; @@ -114,10 +114,10 @@ export const $selectedInstancePath = computed( return; } return getInstancePath( + instanceSelector, instances, virtualInstances, - temporaryInstances, - instanceSelector + temporaryInstances ); } ); @@ -134,10 +134,10 @@ export const $selectedInstancePathWithRoot = computed( instanceSelector = [...instanceSelector, ROOT_INSTANCE_ID]; } return getInstancePath( + instanceSelector, instances, virtualInstances, - temporaryInstances, - instanceSelector + temporaryInstances ); } ); diff --git a/apps/builder/app/shared/copy-paste/plugin-instance.ts b/apps/builder/app/shared/copy-paste/plugin-instance.ts index 5faeb5f7de5e..c9ae48c63b2f 100644 --- a/apps/builder/app/shared/copy-paste/plugin-instance.ts +++ b/apps/builder/app/shared/copy-paste/plugin-instance.ts @@ -25,6 +25,7 @@ import { findClosestInsertable, } from "../instance-utils"; import { isInstanceDetachable } from "../matcher"; +import { $selectedInstancePath } from "../awareness"; const version = "@webstudio/instance/v0.1"; @@ -203,20 +204,20 @@ export const onCopy = () => { }; export const onCut = () => { - const selectedInstanceSelector = $selectedInstanceSelector.get(); - if (selectedInstanceSelector === undefined) { + const instancePath = $selectedInstancePath.get(); + if (instancePath === undefined) { return; } // @todo tell user they can't delete root - if (selectedInstanceSelector.length === 1) { + if (instancePath.length === 1) { return; } - const data = getTreeData(selectedInstanceSelector); + const data = getTreeData(instancePath[0].instanceSelector); if (data === undefined) { return; } updateWebstudioData((data) => { - deleteInstanceMutable(data, selectedInstanceSelector); + deleteInstanceMutable(data, instancePath); }); if (data === undefined) { return; diff --git a/apps/builder/app/shared/instance-utils.test.tsx b/apps/builder/app/shared/instance-utils.test.tsx index dd070ff6c7c9..6b23621f0dd5 100644 --- a/apps/builder/app/shared/instance-utils.test.tsx +++ b/apps/builder/app/shared/instance-utils.test.tsx @@ -8,17 +8,16 @@ import { expression, renderTemplate, renderData, + ResourceValue, } from "@webstudio-is/template"; import * as defaultMetas from "@webstudio-is/sdk-components-react/metas"; import * as radixMetas from "@webstudio-is/sdk-components-react-radix/metas"; import type { Asset, Breakpoint, - DataSource, Instance, Instances, Prop, - Resource, StyleDecl, StyleDeclKey, StyleSource, @@ -59,7 +58,7 @@ import { $resources, } from "./nano-states"; import { registerContainers } from "./sync"; -import { $awareness, selectInstance } from "./awareness"; +import { $awareness, getInstancePath, selectInstance } from "./awareness"; enableMapSet(); registerContainers(); @@ -122,14 +121,6 @@ const createInstance = ( return { type: "instance", id, component, children }; }; -const createInstancePair = ( - id: Instance["id"], - component: string, - children: Instance["children"] -): [Instance["id"], Instance] => { - return [id, { type: "instance", id, component, children }]; -}; - const createStyleDecl = ( styleSourceId: string, breakpointId: string, @@ -746,98 +737,145 @@ const getWebstudioDataStub = ( describe("delete instance", () => { test("delete instance with its children", () => { - // body - // box1 - // box11 - // box2 - const instances = new Map([ - createInstancePair("body", "Body", [ - { type: "id", value: "box1" }, - { type: "id", value: "box2" }, - ]), - createInstancePair("box1", "Box", [{ type: "id", value: "box11" }]), - createInstancePair("box11", "Box", []), - createInstancePair("box2", "Box", []), - ]); - $registeredComponentMetas.set(createFakeComponentMetas({})); - const data = getWebstudioDataStub({ instances }); - deleteInstanceMutable(data, ["box1", "body"]); - expect(data.instances).toEqual( - new Map([ - createInstancePair("body", "Body", [{ type: "id", value: "box2" }]), - createInstancePair("box2", "Box", []), - ]) + const data = renderData( + <$.Body ws:id="bodyId"> + <$.Box ws:id="boxId"> + <$.Box ws:id="sectionId"> + + <$.Box ws:id="divId"> + ); + expect(data.instances.size).toEqual(4); + expect(data.instances.get("bodyId")?.children.length).toEqual(2); + deleteInstanceMutable( + data, + getInstancePath(["boxId", "bodyId"], data.instances) + ); + expect(data.instances.size).toEqual(2); + expect(data.instances.get("bodyId")?.children.length).toEqual(1); + }); + + test("delete instance from collection", () => { + const data = renderData( + <$.Body ws:id="bodyId"> + + <$.Box ws:id="boxId"> + + + ); + expect(data.instances.size).toEqual(3); + expect(data.instances.get("collectionId")?.children.length).toEqual(1); + deleteInstanceMutable( + data, + getInstancePath( + ["boxId", "collectionId[0]", "collectionId", "bodyId"], + data.instances + ) + ); + expect(data.instances.size).toEqual(2); + expect(data.instances.get("collectionId")?.children.length).toEqual(0); }); test("delete instance from collection item", () => { - // body - // list - // box - const instances = new Map([ - createInstancePair("body", "Body", [{ type: "id", value: "list" }]), - createInstancePair("list", collectionComponent, [ - { type: "id", value: "box" }, - ]), - createInstancePair("box", "Box", []), - ]); - $registeredComponentMetas.set(createFakeComponentMetas({})); - const data = getWebstudioDataStub({ instances }); - deleteInstanceMutable(data, ["box", "list[0]", "list", "body"]); - expect(data.instances).toEqual( - new Map([ - createInstancePair("body", "Body", [{ type: "id", value: "list" }]), - createInstancePair("list", collectionComponent, []), - ]) + const data = renderData( + <$.Body ws:id="bodyId"> + + <$.Box ws:id="boxId"> + <$.Text ws:id="textId"> + + + ); + expect(data.instances.size).toEqual(4); + expect(data.instances.get("boxId")?.children.length).toEqual(1); + deleteInstanceMutable( + data, + getInstancePath( + ["textId", "boxId", "collectionId[0]", "collectionId", "bodyId"], + data.instances + ) + ); + expect(data.instances.size).toEqual(3); + expect(data.instances.get("boxId")?.children.length).toEqual(0); }); - test("delete resource along with variable", () => { - const instances = toMap([ - createInstance("body", "Body", [{ type: "id", value: "box" }]), - createInstance("box", "Box", []), - ]); - const resources = toMap([ - { - id: "resourceId", - name: "My Resource", - url: `""`, - method: "get", - headers: [], - }, - ]); - const dataSources = toMap([ - { - id: "resourceVariableId", - scopeInstanceId: "box", - name: "My Resource Variable", - type: "resource", - resourceId: "resourceId", - }, - ]); - $registeredComponentMetas.set(createFakeComponentMetas({})); + test("delete resource bound to variable", () => { + const myResource = new ResourceValue("My Resource", { + url: expression`""`, + method: "get", + headers: [], + }); + const data = renderData( + <$.Body ws:id="bodyId"> + <$.Box ws:id="boxId" vars={expression`${myResource}`}> + + ); + expect(data.resources.size).toEqual(1); + expect(data.dataSources.size).toEqual(1); + deleteInstanceMutable( + data, + getInstancePath(["boxId", "bodyId"], data.instances) + ); + expect(data.resources.size).toEqual(0); + expect(data.dataSources.size).toEqual(0); + }); - const data = getWebstudioDataStub({ instances, resources, dataSources }); - deleteInstanceMutable(data, ["box", "body"]); + test("delete resource bound to prop", () => { + const myResource = new ResourceValue("My Resource", { + url: expression`""`, + method: "get", + headers: [], + }); + const data = renderData( + <$.Body ws:id="bodyId"> + <$.Box ws:id="boxId" action={myResource}> + + ); + expect(data.resources.size).toEqual(1); + expect(data.props.size).toEqual(1); + deleteInstanceMutable( + data, + getInstancePath(["boxId", "bodyId"], data.instances) + ); + expect(data.resources.size).toEqual(0); + expect(data.props.size).toEqual(0); + }); - expect(data.instances).toEqual(toMap([createInstance("body", "Body", [])])); - expect(data.dataSources).toEqual(new Map()); - expect(data.resources).toEqual(new Map()); + test("delete unknown instance (just in case)", () => { + const data = renderData( + <$.Body ws:id="bodyId"> + <$.Invalid ws:id="invalidId"> + + ); + expect(data.instances.size).toEqual(2); + deleteInstanceMutable( + data, + getInstancePath(["invalidId", "bodyId"], data.instances) + ); + expect(data.instances.size).toEqual(1); }); - test("delete instance without meta", () => { - // body - // invalid - const instances = new Map([ - createInstancePair("body", "Body", [{ type: "id", value: "invalid" }]), - createInstancePair("invalid", "Invalid", []), - ]); - $registeredComponentMetas.set(new Map()); - const data = getWebstudioDataStub({ instances }); - deleteInstanceMutable(data, ["invalid", "body"]); - expect(data.instances).toEqual( - new Map([createInstancePair("body", "Body", [])]) + test("delete slot fragment along with last child", () => { + const data = renderData( + <$.Body ws:id="bodyId"> + <$.Slot ws:id="slotId"> + <$.Fragment ws:id="fragmentId"> + <$.Box ws:id="boxId"> + + + + ); + expect(data.instances.size).toEqual(4); + expect(data.instances.get("fragmentId")?.children.length).toEqual(1); + deleteInstanceMutable( + data, + getInstancePath( + ["boxId", "fragmentId", "slotId", "bodyId"], + data.instances + ) ); + expect(data.instances.size).toEqual(2); + expect(data.instances.get("slotId")?.children.length).toEqual(0); }); }); diff --git a/apps/builder/app/shared/instance-utils.ts b/apps/builder/app/shared/instance-utils.ts index da4e8be4ca15..766af1c5e53d 100644 --- a/apps/builder/app/shared/instance-utils.ts +++ b/apps/builder/app/shared/instance-utils.ts @@ -58,7 +58,13 @@ import { humanizeString } from "./string-utils"; import { serverSyncStore } from "./sync"; import { setDifference, setUnion } from "./shim"; import { breakCyclesMutable, findCycles } from "@webstudio-is/project-build"; -import { $awareness, $selectedPage, selectInstance } from "./awareness"; +import { + $awareness, + $selectedPage, + getInstancePath, + selectInstance, + type InstancePath, +} from "./awareness"; import { findClosestNonTextualContainer, findClosestInstanceMatchingFragment, @@ -364,7 +370,10 @@ export const reparentInstance = ( if (reparentDropTarget === undefined) { return; } - deleteInstanceMutable(data, sourceInstanceSelector); + deleteInstanceMutable( + data, + getInstancePath(sourceInstanceSelector, data.instances) + ); const { newInstanceIds } = insertWebstudioFragmentCopy({ data, fragment, @@ -394,8 +403,8 @@ export const reparentInstance = ( }; export const deleteInstanceMutable = ( - data: WebstudioData, - instanceSelector: InstanceSelector + data: Omit, + instancePath: InstancePath ) => { const { instances, @@ -406,14 +415,11 @@ export const deleteInstanceMutable = ( dataSources, resources, } = data; - let targetInstanceId = instanceSelector[0]; - const parentInstanceId = instanceSelector[1]; + let targetInstance = instancePath[0].instance; let parentInstance = - parentInstanceId === undefined - ? undefined - : instances.get(parentInstanceId); - const grandparentInstanceId = instanceSelector[2]; - const grandparentInstance = instances.get(grandparentInstanceId); + instancePath.length > 1 ? instancePath[1]?.instance : undefined; + const grandparentInstance = + instancePath.length > 2 ? instancePath[2]?.instance : undefined; // delete parent fragment too if its last child is going to be deleted // use case for slots: slot became empty and remove display: contents @@ -423,18 +429,13 @@ export const deleteInstanceMutable = ( parentInstance.children.length === 1 && grandparentInstance ) { - targetInstanceId = parentInstance.id; - parentInstance = grandparentInstance; - } - - // skip parent fake "item" instance and use grandparent collection as parent - if (grandparentInstance?.component === collectionComponent) { + targetInstance = parentInstance; parentInstance = grandparentInstance; } const instanceIds = findTreeInstanceIdsExcludingSlotDescendants( instances, - targetInstanceId + targetInstance.id ); const localStyleSourceIds = findLocalStyleSourcesWithinInstances( styleSources.values(), @@ -446,7 +447,7 @@ export const deleteInstanceMutable = ( if (parentInstance) { removeByMutable( parentInstance.children, - (child) => child.type === "id" && child.value === targetInstanceId + (child) => child.type === "id" && child.value === targetInstance.id ); } @@ -457,13 +458,13 @@ export const deleteInstanceMutable = ( for (const prop of props.values()) { if (instanceIds.has(prop.instanceId)) { props.delete(prop.id); + if (prop.type === "resource") { + resources.delete(prop.value); + } } } for (const dataSource of dataSources.values()) { - if ( - dataSource.scopeInstanceId !== undefined && - instanceIds.has(dataSource.scopeInstanceId) - ) { + if (instanceIds.has(dataSource.scopeInstanceId)) { dataSources.delete(dataSource.id); if (dataSource.type === "resource") { resources.delete(dataSource.resourceId);