fix: improve board grid interactions
This commit is contained in:
1 parent
915af9ae96
commit
2f28f45ff1
10 files changed
+189
-16
No files matched your search
@@ -219,8 +219,9 @@ Press and hold the handle, move the pointer, and release it when the tile has th
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
To move a tile in edit mode, press and hold anywhere on the tile except one of its buttons or controls, move it to the desired position, and release it.
|
To move a tile in edit mode, press and hold its content, move it to the desired position, and release it.
|
||||||
The tile snaps to the grid and neighboring tiles move out of the way automatically.
|
Widget content is disabled while editing so its controls do not intercept the drag; use the tile's settings button for edit actions.
|
||||||
|
The tile snaps to the grid. Equally-sized tiles swap when one is dropped into the other's slot, while other neighboring tiles move out of the way automatically.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,7 @@
|
|||||||
background-color: var(--background-color) !important;
|
background-color: var(--background-color) !important;
|
||||||
border-color: var(--border-color) !important;
|
border-color: var(--border-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editModeInertContent {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GridStack's floating mode lets Homarr keep intentional empty rows, but it
|
||||||
|
* disables GridStack's built-in swap path. Restore the intuitive subset: when
|
||||||
|
* an item lands exactly in an equally-sized item's former slot, exchange the
|
||||||
|
* two slots and leave every other item where it started.
|
||||||
|
*/
|
||||||
|
export const getDropSwapPlacements = (
|
||||||
|
initial: readonly SectionGridPlacement[],
|
||||||
|
current: readonly SectionGridPlacement[],
|
||||||
|
movedItemId: string,
|
||||||
|
): SectionGridPlacement[] | null => {
|
||||||
|
const movedInitially = initial.find((placement) => placement.id === movedItemId);
|
||||||
|
const movedCurrently = current.find((placement) => placement.id === movedItemId);
|
||||||
|
if (!movedInitially || !movedCurrently) return null;
|
||||||
|
if (
|
||||||
|
movedInitially.x === movedCurrently.x &&
|
||||||
|
movedInitially.y === movedCurrently.y &&
|
||||||
|
movedInitially.w === movedCurrently.w &&
|
||||||
|
movedInitially.h === movedCurrently.h
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displaced = initial.find(
|
||||||
|
(placement) =>
|
||||||
|
placement.id !== movedItemId &&
|
||||||
|
placement.x === movedCurrently.x &&
|
||||||
|
placement.y === movedCurrently.y &&
|
||||||
|
placement.w === movedCurrently.w &&
|
||||||
|
placement.h === movedCurrently.h,
|
||||||
|
);
|
||||||
|
if (!displaced) return null;
|
||||||
|
|
||||||
|
return initial.map((placement) => {
|
||||||
|
if (placement.id === movedItemId) {
|
||||||
|
return {
|
||||||
|
...placement,
|
||||||
|
x: displaced.x,
|
||||||
|
y: displaced.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (placement.id === displaced.id) {
|
||||||
|
return {
|
||||||
|
...placement,
|
||||||
|
x: movedInitially.x,
|
||||||
|
y: movedInitially.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return placement;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -23,9 +23,10 @@
|
|||||||
|
|
||||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se,
|
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se,
|
||||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
|
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
|
||||||
width: 28px;
|
width: 44px;
|
||||||
height: 28px;
|
height: 44px;
|
||||||
background-size: 12px 12px;
|
background-size: 16px 16px;
|
||||||
|
z-index: 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { getLogicalTrackSize, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
import { getLogicalTrackSize, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
import { useSectionContext } from "../section-context";
|
import { useSectionContext } from "../section-context";
|
||||||
|
import { getDropSwapPlacements } from "./grid-drop-swap";
|
||||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||||
import { decorateGridResizeHandles } from "./grid-resize-handles";
|
import { decorateGridResizeHandles } from "./grid-resize-handles";
|
||||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||||
@@ -54,6 +55,11 @@ export default function GridEditor({
|
|||||||
const gridRef = useRef<GridStack | null>(null);
|
const gridRef = useRef<GridStack | null>(null);
|
||||||
const applyingControlledLayoutRef = useRef(false);
|
const applyingControlledLayoutRef = useRef(false);
|
||||||
const placementsRef = useRef(placements);
|
const placementsRef = useRef(placements);
|
||||||
|
const dragSnapshotRef = useRef<{
|
||||||
|
grid: GridStack;
|
||||||
|
itemId: string;
|
||||||
|
placements: SectionGridPlacement[];
|
||||||
|
} | null>(null);
|
||||||
const entriesRef = useRef([...items, ...innerSections]);
|
const entriesRef = useRef([...items, ...innerSections]);
|
||||||
const acceptWidgetRef = useRef<(element: Element) => boolean>(() => false);
|
const acceptWidgetRef = useRef<(element: Element) => boolean>(() => false);
|
||||||
const dimensionsRef = useRef({ columnCount, fixedRowCount, rowCount });
|
const dimensionsRef = useRef({ columnCount, fixedRowCount, rowCount });
|
||||||
@@ -205,12 +211,43 @@ export default function GridEditor({
|
|||||||
wrapper.classList.add("grid-stack-editing");
|
wrapper.classList.add("grid-stack-editing");
|
||||||
|
|
||||||
const handleStop = (_event: Event, element: GridItemHTMLElement) => {
|
const handleStop = (_event: Event, element: GridItemHTMLElement) => {
|
||||||
eventHandlersRef.current.commitGrid(element.gridstackNode?.grid ?? grid);
|
const changedGrid = element.gridstackNode?.grid ?? grid;
|
||||||
eventHandlersRef.current.announceNode(element.gridstackNode);
|
const dragSnapshot = dragSnapshotRef.current;
|
||||||
decorateGrid(element.gridstackNode?.grid ?? grid, placementsRef.current);
|
dragSnapshotRef.current = null;
|
||||||
restoreContentHeight(grid);
|
queueMicrotask(() => {
|
||||||
|
if (dragSnapshot?.grid === changedGrid) {
|
||||||
|
const swapped = getDropSwapPlacements(
|
||||||
|
dragSnapshot.placements,
|
||||||
|
getGridPlacements(changedGrid),
|
||||||
|
dragSnapshot.itemId,
|
||||||
|
);
|
||||||
|
if (swapped) {
|
||||||
|
applyingControlledLayoutRef.current = true;
|
||||||
|
try {
|
||||||
|
changedGrid.load(swapped.map(toGridStackWidget), shellCallback);
|
||||||
|
} finally {
|
||||||
|
applyingControlledLayoutRef.current = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eventHandlersRef.current.commitGrid(changedGrid);
|
||||||
|
eventHandlersRef.current.announceNode(element.gridstackNode);
|
||||||
|
decorateGrid(changedGrid, placementsRef.current);
|
||||||
|
restoreContentHeight(grid);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const handleDragStart = () => {
|
const handleDragStart = (_event: Event, element: GridItemHTMLElement) => {
|
||||||
|
const changedGrid = element.gridstackNode?.grid ?? grid;
|
||||||
|
const itemId = element.getAttribute("data-grid-item-id") ?? element.gridstackNode?.id;
|
||||||
|
if (itemId) {
|
||||||
|
dragSnapshotRef.current = {
|
||||||
|
grid: changedGrid,
|
||||||
|
itemId: String(itemId),
|
||||||
|
placements: getGridPlacements(changedGrid),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const currentDimensions = dimensionsRef.current;
|
const currentDimensions = dimensionsRef.current;
|
||||||
if (currentDimensions.fixedRowCount) return;
|
if (currentDimensions.fixedRowCount) return;
|
||||||
|
|
||||||
@@ -224,6 +261,7 @@ export default function GridEditor({
|
|||||||
previousNode: GridStackNode | undefined,
|
previousNode: GridStackNode | undefined,
|
||||||
newNode: GridStackNode | undefined,
|
newNode: GridStackNode | undefined,
|
||||||
) => {
|
) => {
|
||||||
|
dragSnapshotRef.current = null;
|
||||||
queueMicrotask(() => {
|
queueMicrotask(() => {
|
||||||
if (applyingControlledLayoutRef.current) return;
|
if (applyingControlledLayoutRef.current) return;
|
||||||
const snapshots = uniqueGrids([previousNode?.grid, newNode?.grid ?? grid])
|
const snapshots = uniqueGrids([previousNode?.grid, newNode?.grid ?? grid])
|
||||||
@@ -358,7 +396,7 @@ const decorateItem = (element: GridItemHTMLElement, type: SectionGridPlacement["
|
|||||||
element.setAttribute("data-grid-y", String(node.y ?? 0));
|
element.setAttribute("data-grid-y", String(node.y ?? 0));
|
||||||
element.setAttribute("data-grid-w", String(node.w ?? 1));
|
element.setAttribute("data-grid-w", String(node.w ?? 1));
|
||||||
element.setAttribute("data-grid-h", String(node.h ?? 1));
|
element.setAttribute("data-grid-h", String(node.h ?? 1));
|
||||||
decorateGridResizeHandles(element);
|
decorateGridResizeHandles(element, { minW: node.minW, minH: node.minH });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getElementItemType = (element: Element): SectionGridPlacement["type"] | undefined => {
|
const getElementItemType = (element: Element): SectionGridPlacement["type"] | undefined => {
|
||||||
|
|||||||
@@ -129,7 +129,10 @@ const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, an
|
|||||||
if (!itemElement) return;
|
if (!itemElement) return;
|
||||||
|
|
||||||
itemElement.gridstackNode?.grid?.refreshDragHandles(itemElement);
|
itemElement.gridstackNode?.grid?.refreshDragHandles(itemElement);
|
||||||
decorateGridResizeHandles(itemElement);
|
decorateGridResizeHandles(itemElement, {
|
||||||
|
minW: itemElement.gridstackNode?.minW,
|
||||||
|
minH: itemElement.gridstackNode?.minH,
|
||||||
|
});
|
||||||
}, [container, ownerSectionId]);
|
}, [container, ownerSectionId]);
|
||||||
|
|
||||||
if (!entry || !section || !currentLayout) return null;
|
if (!entry || !section || !currentLayout) return null;
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
export const decorateGridResizeHandles = (element: Element) => {
|
import { LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||||
|
|
||||||
|
interface GridResizeMinimum {
|
||||||
|
minW?: number;
|
||||||
|
minH?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decorateGridResizeHandles = (element: HTMLElement, minimum: GridResizeMinimum = {}) => {
|
||||||
|
element.style.minWidth = `${(minimum.minW ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||||
|
element.style.minHeight = `${(minimum.minH ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||||
|
|
||||||
for (const handle of element.querySelectorAll<HTMLElement>(".ui-resizable-handle")) {
|
for (const handle of element.querySelectorAll<HTMLElement>(".ui-resizable-handle")) {
|
||||||
const direction = Array.from(handle.classList).find((className) =>
|
const direction = Array.from(handle.classList).find((className) =>
|
||||||
/^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className),
|
/^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className),
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { getDropSwapPlacements } from "../grid-drop-swap";
|
||||||
|
import type { SectionGridPlacement } from "../use-grid-layout-actions";
|
||||||
|
|
||||||
|
const first: SectionGridPlacement = { id: "first", type: "item", x: 0, y: 0, w: 1, h: 1 };
|
||||||
|
const second: SectionGridPlacement = { id: "second", type: "item", x: 1, y: 0, w: 1, h: 1 };
|
||||||
|
const third: SectionGridPlacement = { id: "third", type: "item", x: 2, y: 0, w: 2, h: 1 };
|
||||||
|
const initial = [first, second, third];
|
||||||
|
|
||||||
|
describe("grid drop swapping", () => {
|
||||||
|
test("swaps equally-sized items when one takes the other's exact slot", () => {
|
||||||
|
const current: SectionGridPlacement[] = [{ ...first, x: 1 }, { ...second, y: 1 }, third];
|
||||||
|
|
||||||
|
expect(getDropSwapPlacements(initial, current, "first")).toEqual([{ ...first, x: 1 }, { ...second, x: 0 }, third]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps push behavior for different sizes or partial placement", () => {
|
||||||
|
const differentSize: SectionGridPlacement[] = [{ ...first, x: 2 }, second, { ...third, y: 1 }];
|
||||||
|
const partialPlacement: SectionGridPlacement[] = [{ ...third, x: 1 }, first, { ...second, y: 1 }];
|
||||||
|
|
||||||
|
expect(getDropSwapPlacements(initial, differentSize, "first")).toBeNull();
|
||||||
|
expect(getDropSwapPlacements(initial, partialPlacement, "third")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does nothing when the item did not move", () => {
|
||||||
|
expect(getDropSwapPlacements(initial, initial, "first")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { LOGICAL_GRID_PITCH } from "../../../layout";
|
||||||
import { decorateGridResizeHandles } from "../grid-resize-handles";
|
import { decorateGridResizeHandles } from "../grid-resize-handles";
|
||||||
|
|
||||||
describe("grid resize handle decoration", () => {
|
describe("grid resize handle decoration", () => {
|
||||||
@@ -10,7 +11,7 @@ describe("grid resize handle decoration", () => {
|
|||||||
<div class="ui-resizable-handle ui-resizable-se"></div>
|
<div class="ui-resizable-handle ui-resizable-se"></div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
decorateGridResizeHandles(item);
|
decorateGridResizeHandles(item, { minW: 2, minH: 3 });
|
||||||
|
|
||||||
const east = item.querySelector<HTMLElement>(".ui-resizable-e");
|
const east = item.querySelector<HTMLElement>(".ui-resizable-e");
|
||||||
const southeast = item.querySelector<HTMLElement>(".ui-resizable-se");
|
const southeast = item.querySelector<HTMLElement>(".ui-resizable-se");
|
||||||
@@ -20,5 +21,7 @@ describe("grid resize handle decoration", () => {
|
|||||||
expect(southeast?.dataset.testid).toBe("board-grid-resize-handle");
|
expect(southeast?.dataset.testid).toBe("board-grid-resize-handle");
|
||||||
expect(southeast?.dataset.gridResizeDirection).toBe("se");
|
expect(southeast?.dataset.gridResizeDirection).toBe("se");
|
||||||
expect(southeast?.getAttribute("aria-hidden")).toBe("true");
|
expect(southeast?.getAttribute("aria-hidden")).toBe("true");
|
||||||
|
expect(item.style.minWidth).toBe(`${2 * LOGICAL_GRID_PITCH}px`);
|
||||||
|
expect(item.style.minHeight).toBe(`${3 * LOGICAL_GRID_PITCH}px`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+33
-2
@@ -208,6 +208,8 @@ describe("Board grid", () => {
|
|||||||
await expect(firstItem.locator(`[data-grid-id="${fixture.firstItemId}"]`)).toHaveAttribute("role", "group");
|
await expect(firstItem.locator(`[data-grid-id="${fixture.firstItemId}"]`)).toHaveAttribute("role", "group");
|
||||||
await expectTargetSizeAsync(getEditorEntry(firstItem));
|
await expectTargetSizeAsync(getEditorEntry(firstItem));
|
||||||
await expectTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'));
|
await expectTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'));
|
||||||
|
await expectLogicalTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'), 44);
|
||||||
|
await expect(firstItem.locator("[data-board-grid-inert-content]")).toHaveAttribute("inert", "");
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Add board content" }).click();
|
await page.getByRole("button", { name: "Add board content" }).click();
|
||||||
await expect(page.getByRole("menuitem", { name: "New section", exact: true })).toBeVisible();
|
await expect(page.getByRole("menuitem", { name: "New section", exact: true })).toBeVisible();
|
||||||
@@ -220,7 +222,11 @@ describe("Board grid", () => {
|
|||||||
|
|
||||||
const railBoxBeforeMainDrag = await expectBoundingBoxAsync(rail);
|
const railBoxBeforeMainDrag = await expectBoundingBoxAsync(rail);
|
||||||
const canvasScale = await readCanvasScaleAsync(canvas);
|
const canvasScale = await readCanvasScaleAsync(canvas);
|
||||||
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale * 4, 0);
|
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale, 0);
|
||||||
|
await expect(firstItem).toHaveAttribute("data-grid-x", "1");
|
||||||
|
await expect(secondItem).toHaveAttribute("data-grid-x", "0");
|
||||||
|
|
||||||
|
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale * 3, 0);
|
||||||
await expect(firstItem).toHaveAttribute("data-grid-x", "4");
|
await expect(firstItem).toHaveAttribute("data-grid-x", "4");
|
||||||
expect(gridLayoutsOverlap(await readGridLayoutAsync(firstItem), await readGridLayoutAsync(secondItem))).toBe(
|
expect(gridLayoutsOverlap(await readGridLayoutAsync(firstItem), await readGridLayoutAsync(secondItem))).toBe(
|
||||||
false,
|
false,
|
||||||
@@ -279,7 +285,23 @@ describe("Board grid", () => {
|
|||||||
await expect(railKeyboardEntry).toHaveAttribute("data-keyboard-editing", "false");
|
await expect(railKeyboardEntry).toHaveAttribute("data-keyboard-editing", "false");
|
||||||
|
|
||||||
const dynamicResizeHandle = dynamicSection.locator(':scope > [data-testid="board-grid-resize-handle"]');
|
const dynamicResizeHandle = dynamicSection.locator(':scope > [data-testid="board-grid-resize-handle"]');
|
||||||
await dragLocatorByAsync(page, dynamicResizeHandle, 0, logicalCellPitch * canvasScale);
|
await expectLogicalTargetSizeAsync(dynamicResizeHandle, 44);
|
||||||
|
await dragLocatorByAsync(page, dynamicResizeHandle, 0, -logicalCellPitch * canvasScale);
|
||||||
|
await expect(dynamicSection).toHaveAttribute("data-grid-h", "1");
|
||||||
|
|
||||||
|
const minimumSectionBox = await expectBoundingBoxAsync(dynamicSection);
|
||||||
|
const minimumHandleBox = await expectBoundingBoxAsync(dynamicResizeHandle);
|
||||||
|
const resizeStartX = minimumHandleBox.x + minimumHandleBox.width / 2;
|
||||||
|
const resizeStartY = minimumHandleBox.y + minimumHandleBox.height / 2;
|
||||||
|
await page.mouse.move(resizeStartX, resizeStartY);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(resizeStartX, resizeStartY - logicalCellPitch * canvasScale * 2, { steps: 12 });
|
||||||
|
const constrainedSectionBox = await expectBoundingBoxAsync(dynamicSection);
|
||||||
|
expect(Math.abs(constrainedSectionBox.height - minimumSectionBox.height)).toBeLessThan(1);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(dynamicSection).toHaveAttribute("data-grid-h", "1");
|
||||||
|
|
||||||
|
await dragLocatorByAsync(page, dynamicResizeHandle, 0, logicalCellPitch * canvasScale * 2);
|
||||||
await expect(dynamicSection).toHaveAttribute("data-grid-h", "3");
|
await expect(dynamicSection).toHaveAttribute("data-grid-h", "3");
|
||||||
await expect(belowItem).toHaveAttribute("data-grid-y", "3");
|
await expect(belowItem).toHaveAttribute("data-grid-y", "3");
|
||||||
expect(gridLayoutsOverlap(await readGridLayoutAsync(dynamicSection), await readGridLayoutAsync(belowItem))).toBe(
|
expect(gridLayoutsOverlap(await readGridLayoutAsync(dynamicSection), await readGridLayoutAsync(belowItem))).toBe(
|
||||||
@@ -916,6 +938,15 @@ const expectTargetSizeAsync = async (locator: Locator) => {
|
|||||||
expect(box.height).toBeGreaterThanOrEqual(24);
|
expect(box.height).toBeGreaterThanOrEqual(24);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const expectLogicalTargetSizeAsync = async (locator: Locator, minimumSize: number) => {
|
||||||
|
const dimensions = await locator.evaluate((element) => ({
|
||||||
|
width: (element as HTMLElement).offsetWidth,
|
||||||
|
height: (element as HTMLElement).offsetHeight,
|
||||||
|
}));
|
||||||
|
expect(dimensions.width).toBeGreaterThanOrEqual(minimumSize);
|
||||||
|
expect(dimensions.height).toBeGreaterThanOrEqual(minimumSize);
|
||||||
|
};
|
||||||
|
|
||||||
const readGridLayoutAsync = async (locator: Locator) => {
|
const readGridLayoutAsync = async (locator: Locator) => {
|
||||||
const [x, y, width, height] = await Promise.all([
|
const [x, y, width, height] = await Promise.all([
|
||||||
locator.getAttribute("data-grid-x"),
|
locator.getAttribute("data-grid-x"),
|
||||||
|
|||||||
Reference in new issue
Block a user