Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): handle components multiselection - Wf-148 #707

Merged
merged 13 commits into from
Jan 7, 2025
Merged
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
Loading