From 3722978f50ff61ddab9923107a56eeba2a5f1bd3 Mon Sep 17 00:00:00 2001 From: Alexandre Rousseau Date: Thu, 19 Dec 2024 15:14:30 +0100 Subject: [PATCH 01/13] feat(ui): allow select multiple components (UI mode) - WF-148 --- src/ui/src/builder/BuilderApp.vue | 33 +++++++--- src/ui/src/builder/builderManager.spec.ts | 55 ++++++++++++++++ src/ui/src/builder/builderManager.ts | 64 ++++++++++++++++--- .../src/builder/settings/BuilderSettings.vue | 6 +- .../settings/BuilderSettingsAPICode.vue | 4 +- .../settings/BuilderSettingsActions.vue | 6 +- .../settings/BuilderSettingsBinding.vue | 6 +- .../settings/BuilderSettingsHandlers.vue | 6 +- .../builder/settings/BuilderSettingsMain.vue | 6 +- .../settings/BuilderSettingsProperties.vue | 6 +- .../settings/BuilderSettingsVisibility.vue | 6 +- .../BuilderSidebarComponentTreeBranch.vue | 12 ++-- .../components/core/layout/CoreSidebar.vue | 2 +- .../src/components/core/layout/CoreStep.vue | 2 +- src/ui/src/components/core/layout/CoreTab.vue | 2 +- src/ui/src/components/core/root/CoreRoot.vue | 2 +- .../workflows/WorkflowsWorkflow.vue | 24 +++---- .../workflows/abstract/WorkflowsNode.vue | 2 +- .../workflows/base/WorkflowMiniMap.vue | 2 +- src/ui/src/renderer/ComponentProxy.vue | 2 +- 20 files changed, 190 insertions(+), 58 deletions(-) create mode 100644 src/ui/src/builder/builderManager.spec.ts diff --git a/src/ui/src/builder/BuilderApp.vue b/src/ui/src/builder/BuilderApp.vue index 739e924ec..1dc86f374 100644 --- a/src/ui/src/builder/BuilderApp.vue +++ b/src/ui/src/builder/BuilderApp.vue @@ -13,7 +13,7 @@ @@ -82,6 +82,7 @@ import BuilderTooltip from "./BuilderTooltip.vue"; import BuilderAsyncLoader from "./BuilderAsyncLoader.vue"; import BuilderPanelSwitcher from "./panels/BuilderPanelSwitcher.vue"; import { WDS_CSS_PROPERTIES } from "@/wds/tokens"; +import { SelectionStatus } from "./builderManager"; const BuilderSettings = defineAsyncComponent({ loader: () => import("./settings/BuilderSettings.vue"), @@ -139,7 +140,7 @@ const { } = useComponentActions(wf, ssbm); const builderMode = computed(() => ssbm.getMode()); -const selectedId = computed(() => ssbm.getSelection()?.componentId); +const selectedId = computed(() => ssbm.firstSelectedId.value); function handleKeydown(ev: KeyboardEvent): void { if (ev.key == "Escape") { @@ -162,9 +163,12 @@ function handleKeydown(ev: KeyboardEvent): void { return; } - if (!ssbm.isSelectionActive()) return; + if (!ssbm.isSingleSelectionActive.value || !ssbm.firstSelectedItem.value) { + return; + } + const { componentId: selectedId, instancePath: selectedInstancePath } = - ssbm.getSelection(); + ssbm.firstSelectedItem.value; if (ev.key == "Delete") { if (!isDeleteAllowed(selectedId)) return; @@ -234,9 +238,22 @@ function handleRendererClick(ev: PointerEvent): void { if (!targetEl) return; const targetId = targetEl.dataset.writerId; const targetInstancePath = targetEl.dataset.writerInstancePath; - if (targetId !== ssbm.getSelectedId()) { - ev.preventDefault(); - ev.stopPropagation(); + + const isAlreadySelected = ssbm.isComponentIdSelected(targetId); + + if ( + isAlreadySelected && + ssbm.selectionStatus.value !== SelectionStatus.Multiple + ) { + return; + } + + ev.preventDefault(); + ev.stopPropagation(); + + if (ev.shiftKey || ev.ctrlKey) { + ssbm.appendSelection(targetId, targetInstancePath, "click"); + } else { ssbm.setSelection(targetId, targetInstancePath, "click"); } } diff --git a/src/ui/src/builder/builderManager.spec.ts b/src/ui/src/builder/builderManager.spec.ts new file mode 100644 index 000000000..c2cae734a --- /dev/null +++ b/src/ui/src/builder/builderManager.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { generateBuilderManager, SelectionStatus } from "./builderManager"; + +describe(generateBuilderManager.name, () => { + describe("selection", () => { + it("should select an element", () => { + const { + setSelection, + isComponentIdSelected, + selectionStatus, + firstSelectedId, + } = generateBuilderManager(); + + setSelection("componentId", "instancePath", "click"); + + expect(firstSelectedId.value).toBe("componentId"); + expect(isComponentIdSelected("componentId")).toBeTruthy(); + expect(selectionStatus.value).toBe(SelectionStatus.Single); + }); + + it("should select multiple element", () => { + const { + setSelection, + appendSelection, + isComponentIdSelected, + selectionStatus, + firstSelectedId, + } = generateBuilderManager(); + + setSelection("componentId", "instancePath", "click"); + appendSelection("componentId2", "instancePath2", "click"); + + expect(firstSelectedId.value).toBe("componentId"); + expect(isComponentIdSelected("componentId")).toBeTruthy(); + expect(isComponentIdSelected("componentId2")).toBeTruthy(); + expect(selectionStatus.value).toBe(SelectionStatus.Multiple); + }); + + it("should clear the selection an element", () => { + const { + setSelection, + isComponentIdSelected, + selectionStatus, + firstSelectedId, + } = generateBuilderManager(); + + setSelection("componentId", "instancePath", "click"); + setSelection(null); + + expect(firstSelectedId.value).toBeUndefined(); + expect(isComponentIdSelected("componentId")).toBeFalsy(); + expect(selectionStatus.value).toBe(SelectionStatus.None); + }); + }); +}); diff --git a/src/ui/src/builder/builderManager.ts b/src/ui/src/builder/builderManager.ts index bc7d0a38e..423d1af95 100644 --- a/src/ui/src/builder/builderManager.ts +++ b/src/ui/src/builder/builderManager.ts @@ -1,4 +1,4 @@ -import { ref, Ref } from "vue"; +import { computed, ref, Ref, watch } from "vue"; import { Component, ClipboardOperation } from "@/writerTypes"; export const CANDIDATE_CONFIRMATION_DELAY_MS = 1500; @@ -61,13 +61,19 @@ type LogEntry = { type SelectionSource = "click" | "tree" | "log"; +export const enum SelectionStatus { + None = 0, + Single = 1, + Multiple = 2, +} + type State = { mode: "ui" | "workflows" | "preview"; selection: { componentId: Component["id"]; instancePath: string; source: SelectionSource; - }; + }[]; clipboard: { operation: ClipboardOperation; jsonSubtree: string; @@ -82,7 +88,7 @@ type State = { export function generateBuilderManager() { const initState: State = { mode: "ui", - selection: null, + selection: [], clipboard: null, mutationTransactionsSnapshot: { undo: null, @@ -105,14 +111,27 @@ export function generateBuilderManager() { }; const setSelection = ( - componentId: Component["id"], + componentId: Component["id"] | null, instancePath?: string, source?: SelectionSource, ) => { if (componentId === null) { - state.value.selection = null; + state.value.selection = []; return; } + + if (state.value.selection.length !== 0) { + state.value.selection = []; + } + + appendSelection(componentId, instancePath, source); + }; + + const appendSelection = ( + componentId: Component["id"], + instancePath?: string, + source?: SelectionSource, + ) => { let resolvedInstancePath = instancePath; if (typeof resolvedInstancePath == "undefined") { const componentFirstElement: HTMLElement = document.querySelector( @@ -122,25 +141,46 @@ export function generateBuilderManager() { componentFirstElement?.dataset.writerInstancePath; } - state.value.selection = { + state.value.selection.push({ componentId, instancePath: resolvedInstancePath, source, - }; + }); }; + const isComponentIdSelected = (componentId: string) => { + return state.value.selection.some((s) => s.componentId === componentId); + }; + + /** @deprecated use `selectionStatus` instead */ const isSelectionActive = () => { - return state.value.selection !== null; + return state.value.selection.length > 0; }; + const selectionStatus = computed(() => { + if (state.value.selection.length === 0) return SelectionStatus.None; + if (state.value.selection.length === 1) return SelectionStatus.Single; + return SelectionStatus.Multiple; + }); + const isSingleSelectionActive = computed( + () => selectionStatus.value === SelectionStatus.Single, + ); + const getSelection = () => { return state.value.selection; }; + /** @deprecated use `firstSelectedId` instead */ const getSelectedId = () => { - return state.value.selection?.componentId; + return state.value.selection[0]?.componentId; }; + const firstSelectedItem = computed(() => state.value.selection[0]); + + const firstSelectedId = computed( + () => state.value.selection[0]?.componentId, + ); + const setClipboard = (clipboard: State["clipboard"]) => { state.value.clipboard = clipboard; }; @@ -312,8 +352,14 @@ export function generateBuilderManager() { getMode, openPanels: ref(new Set<"code" | "log">()), isSettingsBarCollapsed: ref(false), + isComponentIdSelected, + selectionStatus, + isSingleSelectionActive, + firstSelectedId, + firstSelectedItem, isSelectionActive, setSelection, + appendSelection, getSelection, getSelectedId, setClipboard, diff --git a/src/ui/src/builder/settings/BuilderSettings.vue b/src/ui/src/builder/settings/BuilderSettings.vue index 9a1750112..c2399c1d1 100644 --- a/src/ui/src/builder/settings/BuilderSettings.vue +++ b/src/ui/src/builder/settings/BuilderSettings.vue @@ -1,6 +1,6 @@