fix(data-grid): restore desktop filter rule dragging

This commit is contained in:
zipg authored and GitHub committed 2026-08-17 14:47:00 +08:00
1 parent db7463eb70
commit 7d2458a74b
5 files changed
+335 -35

No files matched your search

@@ -67,7 +67,12 @@ const dropTargetIndex = ref<number>();
let ruleIdsBeforeKeyboardAdd: Set<string> | undefined;
let ruleDragContainer: HTMLElement | undefined;
let ruleDragPointerY: number | undefined;
let ruleDragPointerId: number | undefined;
let ruleDragHandle: HTMLElement | undefined;
let ruleAutoScrollFrame: number | undefined;
let ruleDragBodyStyleActive = false;
let ruleDragPreviousBodyUserSelect = "";
let ruleDragPreviousBodyCursor = "";
function columnNameDisplayUnits(value: string) {
return Array.from(value).reduce((total, character) => total + ((character.codePointAt(0) ?? 0) > 0xff ? 2 : 1), 0);
@@ -140,8 +145,11 @@ function stopRuleAutoScroll() {
}
function removeRuleDragListeners() {
window.removeEventListener("dragover", handleRuleContainerDragOver, true);
window.removeEventListener("drop", handleRuleContainerDrop, true);
window.removeEventListener("pointermove", handleRulePointerMove, true);
window.removeEventListener("pointerup", handleRulePointerUp, true);
window.removeEventListener("pointercancel", handleRulePointerCancel, true);
window.removeEventListener("keydown", handleRuleDragKeydown, true);
window.removeEventListener("blur", handleRuleDragWindowBlur);
}
function clearRuleDropTarget() {
@@ -153,14 +161,22 @@ function clearRuleDropTarget() {
function clearRuleDragState() {
stopRuleAutoScroll();
removeRuleDragListeners();
if (ruleDragHandle && ruleDragPointerId !== undefined && ruleDragHandle.hasPointerCapture?.(ruleDragPointerId)) ruleDragHandle.releasePointerCapture(ruleDragPointerId);
if (ruleDragBodyStyleActive) {
document.body.style.userSelect = ruleDragPreviousBodyUserSelect;
document.body.style.cursor = ruleDragPreviousBodyCursor;
}
draggingRuleId.value = undefined;
ruleDragContainer = undefined;
ruleDragPointerY = undefined;
ruleDragPointerId = undefined;
ruleDragHandle = undefined;
ruleDragBodyStyleActive = false;
clearRuleDropTarget();
}
function ruleDropZoneContains(event: DragEvent, bounds: DOMRect) {
return event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top - RULE_DROP_ZONE_EXTENSION && event.clientY <= bounds.bottom + RULE_DROP_ZONE_EXTENSION;
function ruleDropZoneContains(clientX: number, clientY: number, bounds: DOMRect) {
return clientX >= bounds.left && clientX <= bounds.right && clientY >= bounds.top - RULE_DROP_ZONE_EXTENSION && clientY <= bounds.bottom + RULE_DROP_ZONE_EXTENSION;
}
function updateRuleDropTarget(clientY: number) {
@@ -214,25 +230,24 @@ function scheduleRuleAutoScroll() {
if (ruleAutoScrollFrame === undefined) ruleAutoScrollFrame = window.requestAnimationFrame(runRuleAutoScroll);
}
function handleRuleContainerDragOver(event: DragEvent) {
if (!draggingRuleId.value || !ruleDragContainer) return;
function handleRulePointerMove(event: PointerEvent) {
if (!draggingRuleId.value || !ruleDragContainer || event.pointerId !== ruleDragPointerId) return;
const bounds = ruleDragContainer.getBoundingClientRect();
if (!ruleDropZoneContains(event, bounds)) {
if (!ruleDropZoneContains(event.clientX, event.clientY, bounds)) {
stopRuleAutoScroll();
clearRuleDropTarget();
return;
}
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
ruleDragPointerY = event.clientY;
updateRuleDropTarget(event.clientY);
scheduleRuleAutoScroll();
}
function handleRuleContainerDrop(event: DragEvent) {
if (!draggingRuleId.value || !ruleDragContainer) return;
function handleRulePointerUp(event: PointerEvent) {
if (!draggingRuleId.value || !ruleDragContainer || event.pointerId !== ruleDragPointerId) return;
const bounds = ruleDragContainer.getBoundingClientRect();
if (!ruleDropZoneContains(event, bounds)) {
if (!ruleDropZoneContains(event.clientX, event.clientY, bounds)) {
clearRuleDragState();
return;
}
@@ -242,23 +257,44 @@ function handleRuleContainerDrop(event: DragEvent) {
clearRuleDragState();
}
function startRuleDrag(event: DragEvent, id: string) {
if (props.disabled || props.rules.length < 2 || !event.dataTransfer) {
event.preventDefault();
return;
}
function handleRulePointerCancel(event?: PointerEvent) {
if (event && event.pointerId !== ruleDragPointerId) return;
clearRuleDragState();
}
function handleRuleDragWindowBlur() {
clearRuleDragState();
}
function handleRuleDragKeydown(event: KeyboardEvent) {
if (event.key !== "Escape" || !draggingRuleId.value) return;
event.preventDefault();
clearRuleDragState();
}
function startRulePointerDrag(event: PointerEvent, id: string) {
if (event.button !== 0 || props.disabled || props.rules.length < 2) return;
const handle = event.currentTarget instanceof HTMLElement ? event.currentTarget : undefined;
ruleDragContainer = handle?.closest<HTMLElement>("[data-filter-rules-scroll]") ?? filterBuilderRootRef.value;
if (!ruleDragContainer) {
event.preventDefault();
return;
}
if (!ruleDragContainer || !handle) return;
event.preventDefault();
event.stopPropagation();
clearRuleDropTarget();
draggingRuleId.value = id;
window.addEventListener("dragover", handleRuleContainerDragOver, true);
window.addEventListener("drop", handleRuleContainerDrop, true);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", id);
ruleDragPointerId = event.pointerId;
ruleDragPointerY = event.clientY;
ruleDragHandle = handle;
ruleDragPreviousBodyUserSelect = document.body.style.userSelect;
ruleDragPreviousBodyCursor = document.body.style.cursor;
ruleDragBodyStyleActive = true;
document.body.style.userSelect = "none";
document.body.style.cursor = "grabbing";
handle.setPointerCapture?.(event.pointerId);
window.addEventListener("pointermove", handleRulePointerMove, true);
window.addEventListener("pointerup", handleRulePointerUp, true);
window.addEventListener("pointercancel", handleRulePointerCancel, true);
window.addEventListener("keydown", handleRuleDragKeydown, true);
window.addEventListener("blur", handleRuleDragWindowBlur);
}
function moveRuleByKeyboard(event: KeyboardEvent, id: string, offset: -1 | 1) {
@@ -466,14 +502,16 @@ function blurValueRule(id: string) {
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('clear')"> <Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }} </Button>
</div>
<div v-if="props.rules.length" :class="props.layout === 'text' ? 'space-y-0' : props.layout === 'panel' ? 'space-y-1' : 'space-y-1.5'">
<div v-if="props.rules.length" :class="props.layout === 'popover' ? 'space-y-1.5' : 'space-y-0'">
<template v-for="(rule, index) in props.rules" :key="rule.id">
<div v-if="index > 0 && (props.layout === 'popover' || rule.conjunction === 'OR')" class="flex" :class="props.layout === 'popover' ? 'justify-center' : props.layout === 'text' ? 'justify-start pl-11' : 'justify-start pl-6'">
<Button variant="ghost" size="sm" :class="props.layout === 'text' ? 'h-4 px-1 text-[10px]' : 'h-5 px-2 text-[11px]'" @click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })">{{ rule.conjunction }}</Button>
<div v-if="index > 0 && props.layout === 'popover'" class="flex justify-center">
<Button variant="ghost" size="sm" class="h-5 px-2 text-[11px]" @click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })">{{ rule.conjunction }}</Button>
</div>
<div
data-filter-rule-item
:ref="(element) => setFilterRuleElement(rule.id, element)"
class="filter-rule-row relative grid items-center justify-start gap-1.5"
:data-connected="index > 0 && props.layout !== 'popover' ? '' : undefined"
:data-dragging="draggingRuleId === rule.id ? '' : undefined"
:data-drop-position="dropRuleId === rule.id ? dropPosition : undefined"
:class="
@@ -484,16 +522,27 @@ function blurValueRule(id: string) {
: 'grid-cols-[18px_var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]'
"
>
<button
v-if="index > 0 && props.layout !== 'popover'"
type="button"
data-filter-rule-connector
data-filter-conjunction
class="filter-rule-conjunction"
:class="rule.conjunction === 'OR' ? 'border-primary/70 text-primary' : ''"
:data-conjunction="rule.conjunction"
:disabled="props.disabled"
@click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })"
>
{{ rule.conjunction }}
</button>
<button
type="button"
data-filter-drag-handle
class="flex h-6 w-4 cursor-grab items-center justify-center justify-self-center text-muted-foreground/70 outline-none hover:text-foreground focus-visible:text-primary disabled:cursor-default disabled:opacity-30 active:cursor-grabbing"
class="flex h-6 w-4 touch-none cursor-grab items-center justify-center justify-self-center text-muted-foreground/70 outline-none hover:text-foreground focus-visible:text-primary disabled:cursor-default disabled:opacity-30 active:cursor-grabbing"
:disabled="props.disabled || props.rules.length < 2"
:draggable="!props.disabled && props.rules.length > 1"
:aria-label="t('grid.filterBuilderReorderRule')"
:aria-grabbed="draggingRuleId === rule.id"
@dragstart="startRuleDrag($event, rule.id)"
@dragend="clearRuleDragState"
@pointerdown="startRulePointerDrag($event, rule.id)"
@keydown.up="moveRuleByKeyboard($event, rule.id, -1)"
@keydown.down="moveRuleByKeyboard($event, rule.id, 1)"
>
@@ -681,6 +730,45 @@ function blurValueRule(id: string) {
</template>
<style scoped>
.filter-rule-row[data-connected] {
margin-top: 16px;
}
.filter-rule-conjunction {
position: absolute;
z-index: 1;
top: -16px;
left: 0;
display: flex;
width: 24px;
height: 16px;
padding: 0 1px;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 2px;
background: var(--background);
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
line-height: 1;
}
.filter-rule-conjunction:hover {
border-color: var(--primary);
color: var(--foreground);
}
.filter-rule-conjunction:focus-visible {
outline: 1px solid var(--primary);
outline-offset: 1px;
}
.filter-rule-conjunction:disabled {
cursor: default;
opacity: 0.45;
}
.filter-rule-row[data-dragging] {
opacity: 0.45;
}
@@ -479,6 +479,13 @@ describe("DataGridFilterBuilder", () => {
expect(updateRule).toHaveBeenCalledWith("r1", { disabled: true });
expect(add).toHaveBeenCalledTimes(2);
expect(findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[2].props.open).toBe(false);
const ruleItems = findAll(mounted.root, (node) => node.props["data-filter-rule-item"] === "");
const conjunction = findOne(mounted.root, (node) => node.props["data-filter-conjunction"] === "");
expect(ruleItems).toHaveLength(2);
expect(ruleItems[1].props["data-connected"]).toBe("");
expect(hostText(conjunction)).toBe("AND");
dispatch(conjunction, "click");
expect(updateRule).toHaveBeenCalledWith("r2", { conjunction: "OR" });
});
it("opens the first empty rule column search on request", async () => {
@@ -564,7 +571,9 @@ describe("DataGridFilterBuilder", () => {
const handles = findAll(mounted.root, (node) => node.props["data-filter-drag-handle"] === "");
expect(handles).toHaveLength(2);
expect(handles.every((handle) => handle.props.draggable === true)).toBe(true);
expect(handles.every((handle) => handle.props.draggable === undefined)).toBe(true);
expect(handles.every((handle) => typeof handle.props.onPointerdown === "function")).toBe(true);
expect(handles.every((handle) => String(handle.props.class).includes("touch-none"))).toBe(true);
expect(handles[0].props["aria-label"]).toBe("grid.filterBuilderReorderRule");
const arrowDown = dispatch(handles[0], "keydown", { key: "ArrowDown" });
expect(arrowDown.defaultPrevented).toBe(true);
@@ -1050,6 +1059,7 @@ describe("DataGridFilterWorkbench", () => {
const reset = vi.fn();
const clear = vi.fn();
const copySql = vi.fn();
const updateRule = vi.fn();
const mounted = mountComponent(DataGridFilterWorkbench, {
sqlPreview: "WHERE (tenant_id = 7) AND (status = 'open')",
rules: [
@@ -1066,6 +1076,7 @@ describe("DataGridFilterWorkbench", () => {
onReset: reset,
onClear: clear,
onCopySql: copySql,
onUpdateRule: updateRule,
});
await nextTick();
@@ -1076,7 +1087,13 @@ describe("DataGridFilterWorkbench", () => {
expect(hostText(mounted.root)).toContain("WHERE (tenant_id = 7) AND (status = 'open')");
const ruleScroller = findOne(mounted.root, (node) => node.props["data-filter-rules-scroll"] === "");
expect(String(ruleScroller.props.class)).toContain("overflow-auto");
expect(findAll(mounted.root, (node) => node.type === "button" && hostText(node) === "AND")).toHaveLength(0);
const conjunctions = findAll(mounted.root, (node) => node.props["data-filter-conjunction"] === "");
const ruleItems = findAll(mounted.root, (node) => node.props["data-filter-rule-item"] === "");
expect(conjunctions).toHaveLength(1);
expect(hostText(conjunctions[0])).toBe("AND");
expect(ruleItems[1].props["data-connected"]).toBe("");
dispatch(conjunctions[0], "click");
expect(updateRule).toHaveBeenCalledWith("r2", { conjunction: "OR" });
dispatch(
findOne(mounted.root, (node) => node.props["aria-label"] === "grid.copyFilterSql"),
@@ -0,0 +1,195 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App, type Component } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
vi.mock("@lucide/vue", () => {
const icon = defineComponent({ setup: () => () => h("span") });
return { Check: icon, Eye: icon, EyeOff: icon, GripVertical: icon, Plus: icon, Search: icon, Trash2: icon, X: icon };
});
function passthrough(tag = "div") {
return defineComponent({
inheritAttrs: false,
setup(_props, { attrs, slots }) {
return () => h(tag, attrs, [slots.default?.(), slots.footer?.()]);
},
});
}
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
vi.mock("@/components/ui/select", () => ({ Select: passthrough(), SelectContent: passthrough(), SelectItem: passthrough(), SelectTrigger: passthrough("button"), SelectValue: passthrough("span") }));
import DataGridFilterBuilder from "../DataGridFilterBuilder.vue";
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
function rules(count: number): DataGridStructuredFilterRule[] {
return Array.from({ length: count }, (_, index) => ({
id: `r${index + 1}`,
columnName: `column_${index + 1}`,
mode: "equals",
rawValue: String(index + 1),
rawEndValue: "",
conjunction: "AND",
}));
}
async function mountBuilder(ruleCount = 3) {
const move = vi.fn();
const host = document.createElement("div");
document.body.append(host);
const wrapper: Component = defineComponent({
setup() {
return () =>
h("div", { "data-filter-rules-scroll": "" }, [
h(DataGridFilterBuilder, {
rules: rules(ruleCount),
columns: rules(ruleCount).map((rule) => rule.columnName),
filteredColumns: rules(ruleCount).map((rule) => rule.columnName),
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
layout: "panel",
showHeader: false,
showFooter: false,
onMove: move,
}),
]);
},
});
const app = createApp(wrapper);
app.mount(host);
mountedApps.push({ app, host });
await nextTick();
return { host, move };
}
function configureGeometry(host: HTMLElement, scrollerHeight = 120) {
const scroller = host.querySelector<HTMLElement>("[data-filter-rules-scroll]")!;
let scrollTop = 0;
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: scrollerHeight },
scrollHeight: { configurable: true, value: 400 },
scrollTop: {
configurable: true,
get: () => scrollTop,
set: (value: number) => {
scrollTop = Math.max(0, Math.min(400 - scrollerHeight, value));
},
},
});
scroller.getBoundingClientRect = () => domRect(0, scrollerHeight, 400);
host.querySelectorAll<HTMLElement>(".filter-rule-row").forEach((row, index) => {
row.getBoundingClientRect = () => domRect(index * 38 - scrollTop, index * 38 + 28 - scrollTop, 400);
});
return scroller;
}
function domRect(top: number, bottom: number, width: number): DOMRect {
return {
left: 0,
right: width,
top,
bottom,
width,
height: bottom - top,
x: 0,
y: top,
toJSON: () => ({}),
};
}
function dispatchPointer(target: EventTarget, type: string, options: { pointerId?: number; clientX?: number; clientY: number }) {
target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
cancelable: true,
button: 0,
pointerId: options.pointerId ?? 1,
clientX: options.clientX ?? 20,
clientY: options.clientY,
}),
);
}
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
document.body.innerHTML = "";
document.body.style.userSelect = "";
document.body.style.cursor = "";
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("DataGridFilterBuilder pointer dragging", () => {
it("reorders rules with pointer events", async () => {
const { host, move } = await mountBuilder();
configureGeometry(host);
const handle = host.querySelectorAll<HTMLButtonElement>("[data-filter-drag-handle]")[0];
dispatchPointer(handle, "pointerdown", { clientY: 14 });
dispatchPointer(window, "pointermove", { clientY: 100 });
await nextTick();
expect(document.body.style.userSelect).toBe("none");
expect(host.querySelector('[data-drop-position="after"]')).not.toBeNull();
dispatchPointer(window, "pointerup", { clientY: 100 });
expect(move).toHaveBeenCalledOnce();
expect(move).toHaveBeenCalledWith("r1", 2);
expect(document.body.style.userSelect).toBe("");
});
it("cancels an active pointer drag on Escape", async () => {
const { host, move } = await mountBuilder();
configureGeometry(host);
const handle = host.querySelectorAll<HTMLButtonElement>("[data-filter-drag-handle]")[0];
dispatchPointer(handle, "pointerdown", { pointerId: 3, clientY: 14 });
dispatchPointer(window, "pointermove", { pointerId: 3, clientY: 100 });
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
dispatchPointer(window, "pointerup", { pointerId: 3, clientY: 100 });
await nextTick();
expect(move).not.toHaveBeenCalled();
expect(host.querySelector("[data-drop-position]")).toBeNull();
expect(document.body.style.cursor).toBe("");
});
it("auto-scrolls the condition list near its bottom edge", async () => {
const frames = new Map<number, FrameRequestCallback>();
let nextFrameId = 1;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
const frameId = nextFrameId++;
frames.set(frameId, callback);
return frameId;
}),
);
vi.stubGlobal(
"cancelAnimationFrame",
vi.fn((frameId: number) => frames.delete(frameId)),
);
const { host, move } = await mountBuilder(10);
const scroller = configureGeometry(host, 100);
const handle = host.querySelectorAll<HTMLButtonElement>("[data-filter-drag-handle]")[0];
dispatchPointer(handle, "pointerdown", { pointerId: 7, clientY: 14 });
dispatchPointer(window, "pointermove", { pointerId: 7, clientY: 96 });
for (let frameIndex = 0; frameIndex < 8; frameIndex += 1) {
const callbacks = [...frames.values()];
frames.clear();
callbacks.forEach((callback) => callback(frameIndex));
}
expect(scroller.scrollTop).toBeGreaterThan(0);
dispatchPointer(window, "pointercancel", { pointerId: 7, clientY: 96 });
expect(move).not.toHaveBeenCalled();
});
});
+1 -1
View File
@@ -1913,7 +1913,7 @@ export default withEnglishFallback({
rollback: "回滚",
transactionSaveHint: "在事务中提交 {count} 项待保存更改。",
nonTransactionalSaveHint: "逐条保存 {count} 项更改;如果中途失败,前面已成功的更改不会回滚。",
keylessEditWarning: "无主键定位",
keylessEditWarning: "无主键",
keylessEditWarningHint: "这张表没有主键。保存更新或删除时会使用整行原始值作为 WHERE 条件;如果存在完全重复的行,可能会影响多行。",
queryEditReadOnly: "只读结果",
queryEditUnsupported: {
+1 -1
View File
@@ -1852,7 +1852,7 @@ export default withEnglishFallback({
rollback: "回溯",
transactionSaveHint: "在事務中提交 {count} 項待儲存更改。",
nonTransactionalSaveHint: "逐條儲存 {count} 項更改;如果中途失敗,前面已成功的更改不會回溯。",
keylessEditWarning: "無主鍵定位",
keylessEditWarning: "無主鍵",
keylessEditWarningHint: "此資料表沒有主鍵。更新與刪除會在 WHERE 子句中使用所有原始資料列值;完全重複的資料列可能會一起受到影響。",
queryEditReadOnly: "唯讀結果",
queryEditUnsupported: {