Skip to content

Commit

Permalink
Merge pull request #707 from madeindjs/WF-148
Browse files Browse the repository at this point in the history
feat(ui): handle components multiselection - Wf-148
  • Loading branch information
ramedina86 authored Jan 7, 2025
2 parents e8b8954 + 6819f65 commit dc8618b
Show file tree
Hide file tree
Showing 23 changed files with 573 additions and 133 deletions.
37 changes: 28 additions & 9 deletions src/ui/src/builder/BuilderApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<ComponentRenderer
class="componentRenderer"
:class="{
settingsOpen: ssbm.isSelectionActive(),
settingsOpen: ssbm.isSingleSelectionActive,
}"
@dragover="handleRendererDragover"
@dragstart="handleRendererDragStart"
Expand All @@ -25,7 +25,7 @@
</div>

<BuilderSettings
v-if="ssbm.isSelectionActive()"
v-if="ssbm.isSingleSelectionActive"
:key="selectedId ?? 'noneSelected'"
></BuilderSettings>
</div>
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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") {
Expand All @@ -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;
Expand Down Expand Up @@ -234,11 +238,20 @@ 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();
ssbm.setSelection(targetId, targetInstancePath, "click");
const isAlreadySelected = ssbm.isComponentIdSelected(targetId);
if (
isAlreadySelected &&
ssbm.selectionStatus.value !== SelectionStatus.Multiple
) {
return;
}
ev.preventDefault();
ev.stopPropagation();
ssbm.handleSelectionFromEvent(ev, targetId, targetInstancePath, "click");
}
const handleRendererDragStart = (ev: DragEvent) => {
Expand All @@ -251,6 +264,12 @@ const handleRendererDragStart = (ev: DragEvent) => {
const componentId = targetEl.dataset.writerId;
const { type } = wf.getComponentById(componentId);
// we don't support yet dragginfg multiple components in UI. If drag is starting with multiple selections, we select only one component
if (ssbm.selectionStatus.value === SelectionStatus.Multiple) {
ssbm.setSelection(componentId, undefined, "click");
ssbm.isSettingsBarCollapsed.value = true;
}
ev.dataTransfer.setData(
`application/json;writer=${type},${componentId}`,
"{}",
Expand Down
92 changes: 92 additions & 0 deletions src/ui/src/builder/builderManager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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);
});

it("should handle click events", () => {
const {
handleSelectionFromEvent,
isComponentIdSelected,
selectionStatus,
} = generateBuilderManager();

handleSelectionFromEvent(
{ ctrlKey: true } as KeyboardEvent,
"1",
"path",
);

expect(selectionStatus.value).toBe(SelectionStatus.Single);
expect(isComponentIdSelected("1")).toBeTruthy();

handleSelectionFromEvent(
{ ctrlKey: true } as KeyboardEvent,
"2",
"path",
);

expect(selectionStatus.value).toBe(SelectionStatus.Multiple);
expect(isComponentIdSelected("1")).toBeTruthy();
expect(isComponentIdSelected("2")).toBeTruthy();

handleSelectionFromEvent(
{ ctrlKey: true } as KeyboardEvent,
"2",
"path",
);

expect(selectionStatus.value).toBe(SelectionStatus.Single);
expect(isComponentIdSelected("1")).toBeTruthy();
expect(isComponentIdSelected("2")).toBeFalsy();
});
});
});
89 changes: 73 additions & 16 deletions src/ui/src/builder/builderManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ref, Ref } from "vue";
import { computed, ref, Ref } from "vue";
import { Component, ClipboardOperation } from "@/writerTypes";

export const CANDIDATE_CONFIRMATION_DELAY_MS = 1500;
Expand Down Expand Up @@ -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;
Expand All @@ -82,7 +88,7 @@ type State = {
export function generateBuilderManager() {
const initState: State = {
mode: "ui",
selection: null,
selection: [],
clipboard: null,
mutationTransactionsSnapshot: {
undo: null,
Expand All @@ -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(
Expand All @@ -122,25 +141,57 @@ export function generateBuilderManager() {
componentFirstElement?.dataset.writerInstancePath;
}

state.value.selection = {
state.value.selection.push({
componentId,
instancePath: resolvedInstancePath,
source,
};
});
};

const isSelectionActive = () => {
return state.value.selection !== null;
const handleSelectionFromEvent = (
ev: MouseEvent | KeyboardEvent,
componentId: Component["id"],
instancePath?: string,
source?: SelectionSource,
) => {
if (!ev.shiftKey && !ev.ctrlKey && !ev.metaKey) {
return setSelection(componentId, instancePath, source);
}

if (isComponentIdSelected(componentId)) {
removeSelectedComponentId(componentId);
} else {
appendSelection(componentId, instancePath, source);
}
};

const getSelection = () => {
return state.value.selection;
const isComponentIdSelected = (componentId: string) => {
return state.value.selection.some((s) => s.componentId === componentId);
};

const getSelectedId = () => {
return state.value.selection?.componentId;
const removeSelectedComponentId = (componentId: string) => {
const newSelection = state.value.selection.filter(
(c) => c.componentId !== componentId,
);
if (newSelection.length === state.value.selection.length) return;
state.value.selection = newSelection;
};

const selectionStatus = computed<SelectionStatus>(() => {
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 firstSelectedItem = computed(() => state.value.selection[0]);

const firstSelectedId = computed(
() => state.value.selection[0]?.componentId,
);

const setClipboard = (clipboard: State["clipboard"]) => {
state.value.clipboard = clipboard;
};
Expand Down Expand Up @@ -312,10 +363,16 @@ export function generateBuilderManager() {
getMode,
openPanels: ref(new Set<"code" | "log">()),
isSettingsBarCollapsed: ref(false),
isSelectionActive,
isComponentIdSelected,
selectionStatus,
isSingleSelectionActive,
firstSelectedId,
firstSelectedItem,
removeSelectedComponentId,
setSelection,
getSelection,
getSelectedId,
appendSelection,
handleSelectionFromEvent,
selection: computed(() => state.value.selection),
setClipboard,
getClipboard,
openMutationTransaction,
Expand Down
Loading

0 comments on commit dc8618b

Please sign in to comment.