feat(grid): add persistent SQL filter views

This commit is contained in:
zipg authored and GitHub committed 2026-08-15 14:45:52 +08:00
1 parent 8bf4485a75
commit 9e7d80f467
27 files changed
+1428 -107

No files matched your search

+107 -13
View File
@@ -66,6 +66,8 @@ import DataGridToolbar from "@/components/grid/DataGridToolbar.vue";
import DataGridExtractorDialog from "@/components/grid/DataGridExtractorDialog.vue";
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
import DataGridFilterWorkbench from "@/components/grid/DataGridFilterWorkbench.vue";
import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWorkbench.vue";
import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
import EnumCellEditor from "@/components/grid/EnumCellEditor.vue";
import DataGridReadonlyTextSelection from "@/components/grid/DataGridReadonlyTextSelection.vue";
@@ -222,7 +224,7 @@ import { useDataGridSearch, type DataGridSearchMatch } from "@/composables/useDa
import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifecycle";
import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh";
import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface";
import { useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import { createDataGridFilterConditionCache, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import { cloneDataGridStructuredFilterRules, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState, type DataGridCachedServerColumnFilter, type DataGridStructuredFilterCacheState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import { useCellDetailEditor, type UseCellDetailEditorReturn } from "@/composables/useCellDetailEditor";
@@ -847,6 +849,8 @@ const filterBuilderOpen = filterBuilder.open;
const filterBuilderColumnSearch = filterBuilder.columnSearch;
const filteredFilterBuilderColumnOptions = filterBuilder.filteredColumns;
const appliedStructuredWhereInput = filterBuilder.appliedWhereInput;
const draftStructuredWhereInput = ref("");
const filterEditorView = computed(() => settingsStore.editorSettings.dataGridFilterEditorView);
const structuredFilterCount = computed(() => structuredFilterRules.value.filter((rule) => !rule.disabled && !!rule.columnName && filterModeHasCompleteValue(rule.mode, rule.rawValue, rule.rawEndValue)).length);
const hasStructuredFilters = computed(() => !!combineWhereInputs(undefined, appliedStructuredWhereInput.value));
const formatterOpenColumn = ref<number | null>(null);
@@ -1538,7 +1542,9 @@ function cachedStructuredFilterState(): DataGridStructuredFilterCacheState | und
return loadDataGridStructuredFilterState(structuredFilterCacheKey.value, structuredFilterScopeKey.value);
}
const structuredFilterConditionCache = createDataGridFilterConditionCache();
async function buildStructuredWhereFromRules(rules: StructuredFilterRule[]): Promise<string> {
structuredFilterConditionCache.retain(rules.map((rule) => rule.id));
const rulesWithConditions = (
await Promise.all(
rules.map(async (rule) => {
@@ -1549,19 +1555,19 @@ async function buildStructuredWhereFromRules(rules: StructuredFilterRule[]): Pro
const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName);
const usesList = filterModeUsesList(rule.mode);
const usesRange = filterModeUsesRange(rule.mode);
const options = {
databaseType: resolvedDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
columnName: rule.columnName,
columnInfo,
mode: rule.mode,
value: !usesList && filterModeNeedsValue(rule.mode) ? parseFilterValue(rule.rawValue, columnInfo, resolvedDatabaseType.value) : null,
values: usesList ? parseFilterValues(rule.rawValue, columnInfo, resolvedDatabaseType.value) : undefined,
endValue: usesRange ? parseFilterValue(rule.rawEndValue, columnInfo, resolvedDatabaseType.value) : undefined,
};
return {
rule,
condition:
(await buildDataGridContextFilterCondition({
databaseType: resolvedDatabaseType.value,
identifierQuote: connectionStore.connectionIdentifierQuote?.(props.connectionId),
columnName: rule.columnName,
columnInfo,
mode: rule.mode,
value: !usesList && filterModeNeedsValue(rule.mode) ? parseFilterValue(rule.rawValue, columnInfo, resolvedDatabaseType.value) : null,
values: usesList ? parseFilterValues(rule.rawValue, columnInfo, resolvedDatabaseType.value) : undefined,
endValue: usesRange ? parseFilterValue(rule.rawEndValue, columnInfo, resolvedDatabaseType.value) : undefined,
})) ?? null,
condition: await structuredFilterConditionCache.resolve(rule.id, JSON.stringify(options), async () => (await buildDataGridContextFilterCondition(options)) ?? null),
};
}),
)
@@ -1622,6 +1628,14 @@ function updateStructuredFilterRule(ruleId: string, patch: Partial<StructuredFil
filterBuilder.updateRule(ruleId, patch);
}
function moveStructuredFilterRule(ruleId: string, targetIndex: number) {
filterBuilder.moveRule(ruleId, targetIndex);
}
function updateTextFilterPanelHeight(height: number) {
settingsStore.updateEditorSettings({ dataGridTextFilterPanelHeight: height });
}
function resetStructuredFilters() {
filterBuilder.reset();
}
@@ -1675,8 +1689,44 @@ async function applyStructuredFilters() {
await applyWhereFilter();
}
let structuredFilterPreviewRequestId = 0;
async function refreshStructuredFilterPreview() {
const requestId = ++structuredFilterPreviewRequestId;
const preview = await buildStructuredWhereFromRules(structuredFilterRules.value);
if (requestId === structuredFilterPreviewRequestId) draftStructuredWhereInput.value = preview;
}
const filterSqlPreview = computed(() => {
const condition = combineWhereInputs(whereFilterInput.value, draftStructuredWhereInput.value);
return condition ? `WHERE ${condition}` : "";
});
function copyFilterSqlPreview() {
if (!filterSqlPreview.value) return;
void copyText(filterSqlPreview.value);
toast(t("grid.filterSqlCopied"));
}
watch([structuredFilterCacheKey, structuredFilterScopeKey], loadStructuredFilterStateForScope, { immediate: true });
watch(filterEditorView, (view) => {
filterBuilderOpen.value = false;
if (view === "conditions" || view === "text") ensureStructuredFilterRule();
});
const filterPreviewVisible = computed(() => canUseWhereSearch.value && (filterEditorView.value === "conditions" || filterEditorView.value === "text"));
watch(
[filterPreviewVisible, structuredFilterRules],
([visible]) => {
if (visible) void refreshStructuredFilterPreview();
else {
structuredFilterPreviewRequestId++;
draftStructuredWhereInput.value = "";
}
},
{ deep: true, immediate: true },
);
watch(
[structuredFilterRules, appliedStructuredWhereInput, serverColumnFilters],
() => {
@@ -10103,6 +10153,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
v-model:where-input="whereFilterInput"
v-model:order-by-input="orderByInput"
v-model:filter-builder-open="filterBuilderOpen"
:filter-editor-view="filterEditorView"
:columns="props.tableMeta?.columns.map((column) => column.name) ?? props.result.columns"
:condition-columns="conditionColumns"
:identifier-quote="conditionIdentifierQuote"
@@ -10129,6 +10180,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
@reset-filters="resetStructuredFilters"
@clear-filters="clearAllFilters"
@remove-rule="removeStructuredFilterRule"
@move-rule="moveStructuredFilterRule"
@update-rule="updateStructuredFilterRule"
@clear-local-filter="clearLocalFilter"
/>
@@ -10239,6 +10291,48 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
</template>
</DataGridToolbar>
</div>
<DataGridFilterWorkbench
v-if="canUseWhereSearch && filterEditorView === 'conditions'"
:sql-preview="filterSqlPreview"
:rules="structuredFilterRules"
:columns="filterBuilderColumnOptions"
:filtered-columns="filteredFilterBuilderColumnOptions"
:mode-options="filterModeOptions"
:column-search="filterBuilderColumnSearch"
:disabled="!canUseWhereSearch"
@update:column-search="filterBuilderColumnSearch = $event"
@ensure-rule="ensureStructuredFilterRule"
@add-rule="addStructuredFilterRule"
@apply="applyStructuredFilters"
@reset="resetStructuredFilters"
@clear="clearAllFilters"
@copy-sql="copyFilterSqlPreview"
@remove-rule="removeStructuredFilterRule"
@move-rule="moveStructuredFilterRule"
@update-rule="updateStructuredFilterRule"
/>
<DataGridTextFilterWorkbench
v-if="canUseWhereSearch && filterEditorView === 'text'"
:height="settingsStore.editorSettings.dataGridTextFilterPanelHeight"
:sql-preview="filterSqlPreview"
:rules="structuredFilterRules"
:columns="filterBuilderColumnOptions"
:filtered-columns="filteredFilterBuilderColumnOptions"
:mode-options="filterModeOptions"
:column-search="filterBuilderColumnSearch"
:disabled="!canUseWhereSearch"
@update:height="updateTextFilterPanelHeight"
@update:column-search="filterBuilderColumnSearch = $event"
@ensure-rule="ensureStructuredFilterRule"
@add-rule="addStructuredFilterRule"
@apply="applyStructuredFilters"
@reset="resetStructuredFilters"
@clear="clearAllFilters"
@copy-sql="copyFilterSqlPreview"
@remove-rule="removeStructuredFilterRule"
@move-rule="moveStructuredFilterRule"
@update-rule="updateStructuredFilterRule"
/>
<!-- Truncation warning banner -->
<div v-if="showTruncationWarning" class="shrink-0 px-3 py-1 bg-amber-500/10 border-b border-amber-500/20 text-xs text-amber-600 dark:text-amber-400 flex items-center gap-1.5">
<span>{{ t(truncationHintKey, { count: result.rows.length }) }}</span>
@@ -11754,7 +11848,7 @@ const gridContextMenuItems = computed<ContextMenuItem[]>(() => {
<span v-if="totalRowCountBusy" class="text-muted-foreground/70">
{{ t("grid.totalRowCountLoading") }}
</span>
<button v-else-if="showExactTotalCountAction" type="button" class="text-muted-foreground/70 hover:text-foreground hover:underline underline-offset-2 disabled:pointer-events-none" :disabled="manualTotalRowCountLoading" @click="calculateTotalRowCount">
<button v-else-if="showExactTotalCountAction" type="button" class="text-muted-foreground/70 underline underline-offset-2 hover:text-foreground disabled:pointer-events-none" :disabled="manualTotalRowCountLoading" @click="calculateTotalRowCount">
{{ t("grid.calculateTotalRowsInline") }}
</button>
</span>
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from "vue";
import { Eye, EyeOff, Plus, Search, Trash2, X } from "@lucide/vue";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { Check, Eye, EyeOff, GripVertical, Plus, Search, Trash2, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { filterModeNeedsValue, filterModeUsesList, filterModeUsesRange } from "@/lib/dataGrid/dataGridColumnFilter";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import { resolveDataGridFilterRuleDropPlacement } from "@/lib/dataGrid/dataGridFilterRuleDrag";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
@@ -19,6 +20,9 @@ const COLUMN_CONTROL_MIN_WIDTH = 88;
const COLUMN_CONTROL_MAX_WIDTH = 178;
const COLUMN_CONTROL_CHROME_WIDTH = 40;
const COLUMN_CHARACTER_WIDTH = 6.5;
const RULE_DROP_ZONE_EXTENSION = 36;
const RULE_AUTO_SCROLL_EDGE_SIZE = 40;
const RULE_AUTO_SCROLL_MAX_SPEED = 12;
type ValueShortcutHintDay = { date: string; count: number };
const props = withDefaults(
defineProps<{
@@ -30,8 +34,9 @@ const props = withDefaults(
disabled?: boolean;
showHeader?: boolean;
showFooter?: boolean;
layout?: "popover" | "panel" | "text";
}>(),
{ showHeader: true, showFooter: true },
{ showHeader: true, showFooter: true, layout: "popover" },
);
const emit = defineEmits<{
add: [];
@@ -39,11 +44,13 @@ const emit = defineEmits<{
reset: [];
clear: [];
remove: [id: string];
move: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
"update:columnSearch": [value: string];
}>();
const columnSearchInputs = new Map<string, HTMLInputElement>();
const filterRuleElements = new Map<string, HTMLElement>();
const filterBuilderRootRef = ref<HTMLElement>();
const pendingValueFocus = new Set<string>();
const pendingKeyboardAddFocus = new Set<string>();
const openColumnSelectIds = ref(new Set<string>());
@@ -53,7 +60,14 @@ const valueShortcutHintRuleId = ref<string>();
const valueShortcutHintShownDays = ref(readValueShortcutHintShownDays());
const composingEditors = new Set<string>();
const compositionEndedAt = new Map<string, number>();
const draggingRuleId = ref<string>();
const dropRuleId = ref<string>();
const dropPosition = ref<"before" | "after">();
const dropTargetIndex = ref<number>();
let ruleIdsBeforeKeyboardAdd: Set<string> | undefined;
let ruleDragContainer: HTMLElement | undefined;
let ruleDragPointerY: number | undefined;
let ruleAutoScrollFrame: number | undefined;
function columnNameDisplayUnits(value: string) {
return Array.from(value).reduce((total, character) => total + ((character.codePointAt(0) ?? 0) > 0xff ? 2 : 1), 0);
@@ -120,6 +134,141 @@ function setFilterRuleElement(id: string, element: unknown) {
else filterRuleElements.delete(id);
}
function stopRuleAutoScroll() {
if (ruleAutoScrollFrame !== undefined) window.cancelAnimationFrame(ruleAutoScrollFrame);
ruleAutoScrollFrame = undefined;
}
function removeRuleDragListeners() {
window.removeEventListener("dragover", handleRuleContainerDragOver, true);
window.removeEventListener("drop", handleRuleContainerDrop, true);
}
function clearRuleDropTarget() {
dropRuleId.value = undefined;
dropPosition.value = undefined;
dropTargetIndex.value = undefined;
}
function clearRuleDragState() {
stopRuleAutoScroll();
removeRuleDragListeners();
draggingRuleId.value = undefined;
ruleDragContainer = undefined;
ruleDragPointerY = undefined;
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 updateRuleDropTarget(clientY: number) {
if (!draggingRuleId.value) return;
const placement = resolveDataGridFilterRuleDropPlacement(
props.rules.map((rule) => rule.id),
draggingRuleId.value,
props.rules.flatMap((rule) => {
const element = filterRuleElements.get(rule.id);
if (!element) return [];
const bounds = element.getBoundingClientRect();
return [{ id: rule.id, top: bounds.top, bottom: bounds.bottom }];
}),
clientY,
);
if (!placement) {
clearRuleDropTarget();
return;
}
dropRuleId.value = placement.ruleId;
dropPosition.value = placement.position;
dropTargetIndex.value = placement.targetIndex;
}
function ruleAutoScrollSpeed(clientY: number, bounds: DOMRect) {
if (clientY < bounds.top + RULE_AUTO_SCROLL_EDGE_SIZE) {
const intensity = Math.min(1, Math.max(0, (bounds.top + RULE_AUTO_SCROLL_EDGE_SIZE - clientY) / RULE_AUTO_SCROLL_EDGE_SIZE));
return -Math.max(1, Math.ceil(intensity * RULE_AUTO_SCROLL_MAX_SPEED));
}
if (clientY > bounds.bottom - RULE_AUTO_SCROLL_EDGE_SIZE) {
const intensity = Math.min(1, Math.max(0, (clientY - (bounds.bottom - RULE_AUTO_SCROLL_EDGE_SIZE)) / RULE_AUTO_SCROLL_EDGE_SIZE));
return Math.max(1, Math.ceil(intensity * RULE_AUTO_SCROLL_MAX_SPEED));
}
return 0;
}
function runRuleAutoScroll() {
ruleAutoScrollFrame = undefined;
if (!draggingRuleId.value || !ruleDragContainer || ruleDragPointerY === undefined) return;
const bounds = ruleDragContainer.getBoundingClientRect();
const speed = ruleAutoScrollSpeed(ruleDragPointerY, bounds);
if (!speed) return;
const previousScrollTop = ruleDragContainer.scrollTop;
ruleDragContainer.scrollTop += speed;
if (ruleDragContainer.scrollTop === previousScrollTop) return;
updateRuleDropTarget(ruleDragPointerY);
ruleAutoScrollFrame = window.requestAnimationFrame(runRuleAutoScroll);
}
function scheduleRuleAutoScroll() {
if (ruleAutoScrollFrame === undefined) ruleAutoScrollFrame = window.requestAnimationFrame(runRuleAutoScroll);
}
function handleRuleContainerDragOver(event: DragEvent) {
if (!draggingRuleId.value || !ruleDragContainer) return;
const bounds = ruleDragContainer.getBoundingClientRect();
if (!ruleDropZoneContains(event, 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;
const bounds = ruleDragContainer.getBoundingClientRect();
if (!ruleDropZoneContains(event, bounds)) {
clearRuleDragState();
return;
}
event.preventDefault();
updateRuleDropTarget(event.clientY);
if (dropTargetIndex.value !== undefined) emit("move", draggingRuleId.value, dropTargetIndex.value);
clearRuleDragState();
}
function startRuleDrag(event: DragEvent, id: string) {
if (props.disabled || props.rules.length < 2 || !event.dataTransfer) {
event.preventDefault();
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;
}
clearRuleDropTarget();
draggingRuleId.value = id;
window.addEventListener("dragover", handleRuleContainerDragOver, true);
window.addEventListener("drop", handleRuleContainerDrop, true);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", id);
}
function moveRuleByKeyboard(event: KeyboardEvent, id: string, offset: -1 | 1) {
const sourceIndex = props.rules.findIndex((rule) => rule.id === id);
const targetIndex = sourceIndex + offset;
if (props.disabled || sourceIndex < 0 || targetIndex < 0 || targetIndex >= props.rules.length) return;
event.preventDefault();
emit("move", id, targetIndex);
}
function activeColumnIndex(id: string): number {
return activeColumnIndexes.value[id] ?? -1;
}
@@ -241,6 +390,8 @@ watch(
},
);
onBeforeUnmount(clearRuleDragState);
function handleColumnSearchKeydown(event: KeyboardEvent, rule: DataGridStructuredFilterRule) {
if (isImeCompositionKey(event, `column:${rule.id}`)) {
event.stopPropagation();
@@ -282,7 +433,10 @@ function handleValueEditorKeydown(event: KeyboardEvent, editorKey: string) {
return;
}
event.stopPropagation();
if (!event.repeat) addRuleAndOpenColumnSelect();
if (!event.repeat) {
if (props.layout === "text") emit("add");
else addRuleAndOpenColumnSelect();
}
}
function focusValueRule(id: string, index: number, mode: DataGridContextFilterMode) {
@@ -306,20 +460,59 @@ function blurValueRule(id: string) {
</script>
<template>
<div class="w-fit max-w-full space-y-2" :style="filterBuilderStyle">
<div ref="filterBuilderRootRef" class="w-fit max-w-full" :class="props.layout === 'text' ? 'w-full space-y-0' : [props.layout === 'panel' ? '!w-full' : '', 'space-y-2']" :style="filterBuilderStyle">
<div v-if="props.showHeader !== false" class="flex items-center justify-between gap-2">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<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="space-y-1.5">
<div v-if="props.rules.length" :class="props.layout === 'text' ? 'space-y-0' : props.layout === 'panel' ? 'space-y-1' : 'space-y-1.5'">
<template v-for="(rule, index) in props.rules" :key="rule.id">
<div v-if="index > 0" 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 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>
<div :ref="(element) => setFilterRuleElement(rule.id, element)" class="grid grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto] items-center justify-start gap-1.5">
<div
:ref="(element) => setFilterRuleElement(rule.id, element)"
class="filter-rule-row relative grid items-center justify-start gap-1.5"
:data-dragging="draggingRuleId === rule.id ? '' : undefined"
:data-drop-position="dropRuleId === rule.id ? dropPosition : undefined"
:class="
props.layout === 'text'
? 'min-h-7 grid-cols-[18px_22px_var(--filter-builder-column-width)_92px_minmax(140px,1fr)_auto] gap-0.5 border-b border-border/45 px-1 hover:bg-muted/25'
: props.layout === 'panel'
? 'grid-cols-[18px_minmax(128px,1fr)_132px_minmax(158px,1.35fr)_auto]'
: 'grid-cols-[18px_var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]'
"
>
<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"
: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"
@keydown.up="moveRuleByKeyboard($event, rule.id, -1)"
@keydown.down="moveRuleByKeyboard($event, rule.id, 1)"
>
<GripVertical class="h-3.5 w-3.5" />
</button>
<button
v-if="props.layout === 'text'"
type="button"
role="checkbox"
:aria-checked="!rule.disabled"
:aria-label="rule.disabled ? t('grid.filterBuilderEnableRule') : t('grid.filterBuilderDisableRule')"
class="flex h-4 w-4 items-center justify-center justify-self-center border text-primary transition-colors hover:border-primary"
:class="rule.disabled ? 'border-muted-foreground/40 bg-background' : 'border-primary bg-primary/10'"
@click="emit('updateRule', rule.id, { disabled: !rule.disabled })"
>
<Check v-if="!rule.disabled" class="h-3 w-3" />
</button>
<Select :model-value="rule.columnName" :open="openColumnSelectIds.has(rule.id)" :disabled="rule.disabled" @update:model-value="(value: any) => updateRuleColumn(rule, value)" @update:open="(open: boolean) => handleColumnSelectOpen(rule, open)">
<SelectTrigger size="sm" class="h-7 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate">
<SelectTrigger size="sm" class="w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate" :class="props.layout === 'text' ? 'h-6 rounded-none border-0 bg-transparent px-1 shadow-none focus-visible:ring-0' : 'h-7'">
<SelectValue v-if="rule.columnName">{{ rule.columnName }}</SelectValue>
<SelectValue v-else :placeholder="t('grid.filterBuilderColumn')" />
</SelectTrigger>
@@ -356,12 +549,57 @@ function blurValueRule(id: string) {
</SelectContent>
</Select>
<Select :model-value="rule.mode" :disabled="rule.disabled" @update:model-value="(value: any) => emit('updateRule', rule.id, { mode: value as DataGridContextFilterMode })">
<SelectTrigger size="sm" class="h-7 w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate"><SelectValue /></SelectTrigger>
<SelectTrigger size="sm" class="w-full min-w-0 overflow-hidden text-xs [&_[data-slot=select-value]]:min-w-0 [&_[data-slot=select-value]]:truncate" :class="props.layout === 'text' ? 'h-6 rounded-none border-0 bg-transparent px-1 shadow-none focus-visible:ring-0' : 'h-7'"
><SelectValue
/></SelectTrigger>
<SelectContent
><SelectItem v-for="option in props.modeOptions" :key="option.value" :value="option.value" class="rounded-none">{{ t(option.labelKey) }}</SelectItem></SelectContent
>
</Select>
<div v-if="filterModeUsesRange(rule.mode)" class="col-span-3 flex gap-1.5">
<div v-if="props.layout === 'text'" class="flex min-w-0 items-center gap-1" :class="rule.disabled ? 'text-muted-foreground' : ''">
<template v-if="filterModeUsesRange(rule.mode)">
<Input
data-filter-value-editor
:model-value="rule.rawValue"
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeStart')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown($event, `value-start:${rule.id}`)"
/>
<span class="shrink-0 text-[10px] text-muted-foreground">—</span>
<Input
:model-value="rule.rawEndValue"
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderRangeEnd')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown($event, `value-end:${rule.id}`)"
/>
</template>
<Input
v-else-if="filterModeUsesList(rule.mode)"
data-filter-value-editor
:model-value="rule.rawValue"
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValues')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown($event, `value-list:${rule.id}`)"
/>
<Input
v-else-if="filterModeNeedsValue(rule.mode)"
data-filter-value-editor
:model-value="rule.rawValue"
class="h-6 min-w-0 flex-1 rounded-none border-0 border-b border-transparent bg-transparent px-1 text-xs shadow-none focus-visible:border-primary focus-visible:ring-0"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderTextValue')"
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
/>
<span v-else class="px-1 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</span>
</div>
<div v-else-if="filterModeUsesRange(rule.mode)" class="col-start-2 col-span-3 flex gap-1.5">
<Input
data-filter-value-editor
:model-value="rule.rawValue"
@@ -393,7 +631,7 @@ function blurValueRule(id: string) {
data-filter-value-editor
:value="rule.rawValue"
rows="2"
class="col-span-3 min-h-12 resize-y rounded-md border bg-transparent px-2 py-1 text-xs outline-none"
class="col-start-2 col-span-3 min-h-12 resize-y rounded-md border bg-transparent px-2 py-1 text-xs outline-none"
:disabled="rule.disabled"
:placeholder="t('grid.filterBuilderValues')"
@input="emit('updateRule', rule.id, { rawValue: ($event.target as HTMLTextAreaElement).value })"
@@ -415,12 +653,18 @@ function blurValueRule(id: string) {
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
/>
<div v-else class="flex h-7 items-center rounded-md border border-dashed px-2 text-xs text-muted-foreground">{{ t("grid.filterBuilderNoValue") }}</div>
<div v-if="shouldShowValueShortcutHint(rule, index)" class="text-[11px] leading-none text-muted-foreground" :class="usesExpandedLayout(rule.mode) ? 'col-span-3 -mt-0.5' : 'col-start-3 row-start-2 -mt-0.5'">
<div v-if="props.layout !== 'text' && shouldShowValueShortcutHint(rule, index)" class="text-[11px] leading-none text-muted-foreground" :class="usesExpandedLayout(rule.mode) ? 'col-start-2 col-span-3 -mt-0.5' : 'col-start-4 row-start-2 -mt-0.5'">
{{ t("grid.filterBuilderValueShortcutHint") }}
</div>
<div class="flex items-center gap-0.5" :class="usesExpandedLayout(rule.mode) ? 'col-start-4 row-start-1 row-span-2' : 'col-start-4 row-start-1'">
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('updateRule', rule.id, { disabled: !rule.disabled })"><EyeOff v-if="rule.disabled" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="props.rules.length === 1" @click="emit('remove', rule.id)"><X class="h-3.5 w-3.5" /></Button>
<div class="flex items-center gap-0.5" :class="props.layout === 'text' ? 'col-start-6 row-start-1' : usesExpandedLayout(rule.mode) ? 'col-start-5 row-start-1 row-span-2' : 'col-start-5 row-start-1'">
<template v-if="props.layout === 'text'">
<Button variant="ghost" size="icon" class="h-6 w-6" :aria-label="t('grid.filterBuilderAddRule')" @click="emit('add')"><Plus class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-6 w-6" :disabled="props.rules.length === 1" @click="emit('remove', rule.id)"><X class="h-3.5 w-3.5" /></Button>
</template>
<template v-else>
<Button variant="ghost" size="icon" class="h-7 w-7" @click="emit('updateRule', rule.id, { disabled: !rule.disabled })"><EyeOff v-if="rule.disabled" class="h-3.5 w-3.5" /><Eye v-else class="h-3.5 w-3.5" /></Button>
<Button variant="ghost" size="icon" class="h-7 w-7" :disabled="props.rules.length === 1" @click="emit('remove', rule.id)"><X class="h-3.5 w-3.5" /></Button>
</template>
</div>
</div>
</template>
@@ -435,3 +679,28 @@ function blurValueRule(id: string) {
</div>
</div>
</template>
<style scoped>
.filter-rule-row[data-dragging] {
opacity: 0.45;
}
.filter-rule-row[data-drop-position]::before {
position: absolute;
z-index: 10;
right: 0;
left: 0;
height: 2px;
background: var(--primary);
content: "";
pointer-events: none;
}
.filter-rule-row[data-drop-position="before"]::before {
top: -1px;
}
.filter-rule-row[data-drop-position="after"]::before {
bottom: -1px;
}
</style>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { Copy, Plus, RotateCcw, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
const props = defineProps<{
sqlPreview: string;
rules: DataGridStructuredFilterRule[];
columns: string[];
filteredColumns: string[];
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
columnSearch: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
"update:columnSearch": [value: string];
ensureRule: [];
addRule: [];
apply: [];
reset: [];
clear: [];
copySql: [];
removeRule: [id: string];
moveRule: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
}>();
const { t } = useI18n();
const rulesScrollerRef = ref<HTMLElement>();
const previewText = computed(() => props.sqlPreview || t("grid.filterSqlPreviewEmpty"));
onMounted(() => emit("ensureRule"));
watch(
() => props.rules.length,
async (ruleCount, previousRuleCount) => {
if (ruleCount <= previousRuleCount) return;
await nextTick();
window.requestAnimationFrame(() => {
const scroller = rulesScrollerRef.value;
if (scroller) scroller.scrollTop = scroller.scrollHeight;
});
},
);
</script>
<template>
<section data-grid-filter-workbench class="shrink-0 border-b bg-muted/10 text-foreground">
<div ref="rulesScrollerRef" data-filter-rules-scroll class="filter-rules-scroll min-h-11 overflow-auto px-3 py-2">
<DataGridFilterBuilder
class="min-w-[560px]"
:rules="rules"
:columns="columns"
:filtered-columns="filteredColumns"
:mode-options="modeOptions"
:column-search="columnSearch"
:disabled="disabled"
layout="panel"
:show-header="false"
:show-footer="false"
@add="emit('addRule')"
@apply="emit('apply')"
@reset="emit('reset')"
@clear="emit('clear')"
@remove="emit('removeRule', $event)"
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
@update-rule="(id, patch) => emit('updateRule', id, patch)"
@update:column-search="emit('update:columnSearch', $event)"
/>
</div>
<footer class="flex min-w-0 items-center gap-2 border-t bg-background/35 px-2 py-1">
<Button variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-xs" :disabled="disabled || !columns.length" @click="emit('addRule')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }}</Button>
<div class="flex min-w-0 flex-1 items-center gap-1.5 border-l pl-2">
<span class="shrink-0 text-[11px] font-medium text-muted-foreground">{{ t("grid.filterSqlPreview") }}</span>
<code class="min-w-0 flex-1 truncate rounded bg-muted/35 px-1.5 py-0.5 text-[11px]" :class="sqlPreview ? 'text-foreground' : 'text-muted-foreground'" :title="previewText">{{ previewText }}</code>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="!sqlPreview" :aria-label="t('grid.copyFilterSql')" @click="emit('copySql')"><Copy class="h-3 w-3" /></Button>
</div>
<Button variant="outline" size="sm" class="h-7 shrink-0 px-2 text-xs" @click="emit('reset')"><RotateCcw class="mr-1 h-3.5 w-3.5" />{{ t("grid.resetFilterBuilder") }}</Button>
<Button variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-xs text-muted-foreground hover:text-destructive" @click="emit('clear')"><Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }}</Button>
<Button size="sm" class="h-7 shrink-0 px-3 text-xs" :disabled="disabled" @click="emit('apply')">{{ t("grid.applyFilter") }}</Button>
</footer>
</section>
</template>
<style scoped>
.filter-rules-scroll {
max-height: clamp(104px, 27vh, 160px);
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
</style>
@@ -10,6 +10,7 @@ import type { DataGridConditionColumnOption } from "@/composables/useDataGridCon
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import type { DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import type { DataGridFilterEditorView } from "@/stores/settingsStore";
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
type LocalFilterSummary = {
@@ -30,6 +31,7 @@ const props = defineProps<{
compact: boolean;
leadingBorder: boolean;
filterBuilderOpen: boolean;
filterEditorView: DataGridFilterEditorView;
filterButtonActive: boolean;
filterButtonCount: number;
hasLocalColumnFilters: boolean;
@@ -55,6 +57,7 @@ const emit = defineEmits<{
resetFilters: [];
clearFilters: [];
removeRule: [id: string];
moveRule: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
clearLocalFilter: [columnIndex?: number];
}>();
@@ -147,65 +150,68 @@ onUnmounted(onResizeEnd);
<template>
<div ref="containerRef" class="flex flex-1 min-w-0">
<div class="flex flex-1 items-center gap-1 px-2 py-0.5 min-w-0 relative" :class="{ 'border-l': leadingBorder }" :style="wherePaneStyle">
<Popover :open="filterBuilderOpen" @update:open="emit('update:filterBuilderOpen', $event)">
<PopoverTrigger as-child>
<button
type="button"
class="relative flex h-5 w-5 -translate-x-1 shrink-0 items-center justify-center rounded border text-[11px] font-medium transition-colors"
:class="filterButtonActive ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
:disabled="!canUseWhereSearch"
@click="handleFilterButtonClick"
>
<Filter class="h-3 w-3" />
<span v-if="filterButtonCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">{{ filterButtonCount }}</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-fit max-w-[calc(100vw-16px)] gap-2 p-2.5">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="emit('clearFilters')"><Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }}</Button>
</div>
<div v-if="hasLocalColumnFilters" class="space-y-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 py-1.5">
<template v-if="filterEditorView === 'quick'">
<Popover :open="filterBuilderOpen" @update:open="emit('update:filterBuilderOpen', $event)">
<PopoverTrigger as-child>
<button
type="button"
class="relative flex h-5 w-5 -translate-x-1 shrink-0 items-center justify-center rounded border text-[11px] font-medium transition-colors"
:class="filterButtonActive ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/15' : 'border-border/70 text-muted-foreground hover:bg-accent hover:text-foreground'"
:disabled="!canUseWhereSearch"
@click="handleFilterButtonClick"
>
<Filter class="h-3 w-3" />
<span v-if="filterButtonCount" class="absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-1 text-[9px] leading-none text-primary-foreground">{{ filterButtonCount }}</span>
</button>
</PopoverTrigger>
<PopoverContent data-filter-rules-scroll align="start" :collision-padding="8" class="max-h-[var(--reka-popover-content-available-height)] w-fit max-w-[calc(100vw-16px)] gap-2 overflow-y-auto overscroll-contain p-2.5">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2 text-xs font-medium text-primary">
<Filter class="h-3.5 w-3.5 shrink-0" /><span class="truncate">{{ t("grid.localFiltersActive", { count: localFilterCount }) }}</span>
</div>
<Button variant="ghost" size="sm" class="h-6 shrink-0 px-2 text-xs" @click="emit('clearLocalFilter')"><X class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearLocalFiltersShort") }}</Button>
<div class="text-xs font-medium text-foreground">{{ t("grid.filter") }}</div>
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="emit('clearFilters')"><Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }}</Button>
</div>
<div class="space-y-0.5">
<div v-for="summary in localFilterSummaries" :key="summary.columnIndex" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_auto] items-center gap-1.5 rounded border border-primary/10 bg-background/70 px-2 py-0.5 text-xs">
<span class="truncate font-medium text-foreground" :title="summary.columnName">{{ summary.columnName }}</span>
<span class="min-w-0 truncate font-mono text-muted-foreground">
<template v-for="(value, valueIndex) in summary.values" :key="valueIndex"
><span v-if="valueIndex > 0">, </span><span>{{ value }}</span></template
>
<span v-if="summary.hiddenValueCount">{{ t("grid.localFilterMoreValues", { count: summary.hiddenValueCount }) }}</span>
</span>
<Button variant="ghost" size="icon" class="h-5 w-5 text-muted-foreground hover:text-destructive" :title="t('grid.clearFilter')" @click="emit('clearLocalFilter', summary.columnIndex)"><X class="h-3.5 w-3.5" /></Button>
</div>
</div>
</div>
<DataGridFilterBuilder
ref="filterBuilderRef"
:rules="rules"
:columns="[...columns]"
:filtered-columns="filteredColumns"
:mode-options="modeOptions"
:column-search="columnSearch"
:disabled="!canUseWhereSearch"
:show-header="false"
@add="emit('addRule')"
@apply="emit('applyFilters')"
@reset="emit('resetFilters')"
@clear="emit('clearFilters')"
@remove="emit('removeRule', $event)"
@update-rule="updateRule"
@update:column-search="emit('update:columnSearch', $event)"
/>
</PopoverContent>
</Popover>
<div v-if="hasLocalColumnFilters" class="space-y-1.5 rounded-md border border-primary/20 bg-primary/5 px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 items-center gap-2 text-xs font-medium text-primary">
<Filter class="h-3.5 w-3.5 shrink-0" /><span class="truncate">{{ t("grid.localFiltersActive", { count: localFilterCount }) }}</span>
</div>
<Button variant="ghost" size="sm" class="h-6 shrink-0 px-2 text-xs" @click="emit('clearLocalFilter')"><X class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearLocalFiltersShort") }}</Button>
</div>
<div class="space-y-0.5">
<div v-for="summary in localFilterSummaries" :key="summary.columnIndex" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.6fr)_auto] items-center gap-1.5 rounded border border-primary/10 bg-background/70 px-2 py-0.5 text-xs">
<span class="truncate font-medium text-foreground" :title="summary.columnName">{{ summary.columnName }}</span>
<span class="min-w-0 truncate font-mono text-muted-foreground">
<template v-for="(value, valueIndex) in summary.values" :key="valueIndex"
><span v-if="valueIndex > 0">, </span><span>{{ value }}</span></template
>
<span v-if="summary.hiddenValueCount">{{ t("grid.localFilterMoreValues", { count: summary.hiddenValueCount }) }}</span>
</span>
<Button variant="ghost" size="icon" class="h-5 w-5 text-muted-foreground hover:text-destructive" :title="t('grid.clearFilter')" @click="emit('clearLocalFilter', summary.columnIndex)"><X class="h-3.5 w-3.5" /></Button>
</div>
</div>
</div>
<DataGridFilterBuilder
ref="filterBuilderRef"
:rules="rules"
:columns="[...columns]"
:filtered-columns="filteredColumns"
:mode-options="modeOptions"
:column-search="columnSearch"
:disabled="!canUseWhereSearch"
:show-header="false"
@add="emit('addRule')"
@apply="emit('applyFilters')"
@reset="emit('resetFilters')"
@clear="emit('clearFilters')"
@remove="emit('removeRule', $event)"
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
@update-rule="updateRule"
@update:column-search="emit('update:columnSearch', $event)"
/>
</PopoverContent>
</Popover>
</template>
<DataGridConditionEditor
:model-value="whereInput"
kind="where"
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Copy, Plus, RotateCcw, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
import { DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX, DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN } from "@/lib/dataGrid/dataGridTextFilterPanel";
const props = defineProps<{
height: number;
sqlPreview: string;
rules: DataGridStructuredFilterRule[];
columns: string[];
filteredColumns: string[];
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
columnSearch: string;
disabled?: boolean;
}>();
const emit = defineEmits<{
"update:height": [height: number];
"update:columnSearch": [value: string];
ensureRule: [];
addRule: [];
apply: [];
reset: [];
clear: [];
copySql: [];
removeRule: [id: string];
moveRule: [id: string, targetIndex: number];
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
}>();
const { t } = useI18n();
const rulesScrollerRef = ref<HTMLElement>();
const panelHeight = ref(clampHeight(props.height));
const previewText = computed(() => props.sqlPreview || t("grid.filterSqlPreviewEmpty"));
const panelStyle = computed(() => ({ height: `${panelHeight.value}px`, maxHeight: "55vh" }));
let resizeStart: { y: number; height: number } | undefined;
function clampHeight(height: number) {
return Math.min(DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX, Math.max(DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN, Math.round(height)));
}
function restoreDocumentInteraction() {
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
function stopResizeListeners() {
window.removeEventListener("pointermove", resizePanel);
window.removeEventListener("pointerup", finishResize);
window.removeEventListener("pointercancel", finishResize);
}
function startResize(event: PointerEvent) {
if (event.button !== 0) return;
event.preventDefault();
resizeStart = { y: event.clientY, height: panelHeight.value };
document.body.style.cursor = "row-resize";
document.body.style.userSelect = "none";
window.addEventListener("pointermove", resizePanel);
window.addEventListener("pointerup", finishResize);
window.addEventListener("pointercancel", finishResize);
}
function resizePanel(event: PointerEvent) {
if (!resizeStart) return;
panelHeight.value = clampHeight(resizeStart.height + event.clientY - resizeStart.y);
}
function finishResize() {
if (!resizeStart) return;
resizeStart = undefined;
stopResizeListeners();
restoreDocumentInteraction();
emit("update:height", panelHeight.value);
}
function resizePanelByKeyboard(event: KeyboardEvent) {
const step = event.shiftKey ? 24 : 8;
let nextHeight = panelHeight.value;
if (event.key === "ArrowUp") nextHeight -= step;
else if (event.key === "ArrowDown") nextHeight += step;
else if (event.key === "Home") nextHeight = DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN;
else if (event.key === "End") nextHeight = DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX;
else return;
event.preventDefault();
panelHeight.value = clampHeight(nextHeight);
emit("update:height", panelHeight.value);
}
function focusRulesArea(event: PointerEvent) {
(event.currentTarget as HTMLElement).focus({ preventScroll: true });
}
function addRuleFromBlankArea(event: KeyboardEvent) {
if (event.target !== event.currentTarget || event.key !== "Enter" || !event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
event.preventDefault();
event.stopPropagation();
if (!props.disabled && props.columns.length) emit("addRule");
}
onMounted(() => emit("ensureRule"));
onBeforeUnmount(() => {
stopResizeListeners();
restoreDocumentInteraction();
});
watch(
() => props.height,
(height) => {
if (!resizeStart) panelHeight.value = clampHeight(height);
},
);
watch(
() => props.rules.length,
async (ruleCount, previousRuleCount) => {
if (ruleCount <= previousRuleCount) return;
await nextTick();
window.requestAnimationFrame(() => {
const scroller = rulesScrollerRef.value;
if (scroller) scroller.scrollTop = scroller.scrollHeight;
});
},
);
</script>
<template>
<section data-grid-text-filter-workbench class="relative flex shrink-0 flex-col border-b bg-muted/10 text-foreground" :style="panelStyle">
<div
ref="rulesScrollerRef"
data-filter-rules-scroll
tabindex="0"
:aria-label="t('grid.filterTextView')"
class="min-h-0 flex-1 overflow-auto px-2 py-1 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/40"
@keydown="addRuleFromBlankArea"
@pointerdown.self="focusRulesArea"
>
<DataGridFilterBuilder
class="min-w-[520px]"
:rules="rules"
:columns="columns"
:filtered-columns="filteredColumns"
:mode-options="modeOptions"
:column-search="columnSearch"
:disabled="disabled"
layout="text"
:show-header="false"
:show-footer="false"
@add="emit('addRule')"
@apply="emit('apply')"
@reset="emit('reset')"
@clear="emit('clear')"
@remove="emit('removeRule', $event)"
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
@update-rule="(id, patch) => emit('updateRule', id, patch)"
@update:column-search="emit('update:columnSearch', $event)"
/>
</div>
<footer class="flex h-8 min-w-0 shrink-0 items-center gap-1.5 border-t bg-background/45 px-2">
<Tooltip :delay-duration="800">
<TooltipTrigger as-child>
<Button variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs" :disabled="disabled || !columns.length" @click="emit('addRule')"><Plus class="mr-1 h-3.5 w-3.5" />{{ t("grid.filterBuilderAddRule") }}</Button>
</TooltipTrigger>
<TooltipContent side="top">Shift+Enter</TooltipContent>
</Tooltip>
<div class="flex min-w-0 flex-1 items-center gap-1.5 border-l pl-2">
<span class="shrink-0 text-[11px] text-muted-foreground">{{ t("grid.filterSqlPreview") }}</span>
<code class="min-w-0 flex-1 truncate px-1 text-[11px]" :class="sqlPreview ? 'text-foreground' : 'text-muted-foreground'" :title="previewText">{{ previewText }}</code>
<Button variant="ghost" size="icon" class="h-6 w-6 shrink-0" :disabled="!sqlPreview" :aria-label="t('grid.copyFilterSql')" @click="emit('copySql')"><Copy class="h-3 w-3" /></Button>
</div>
<Button variant="outline" size="sm" class="h-6 shrink-0 px-1.5 text-xs" @click="emit('reset')"><RotateCcw class="mr-1 h-3 w-3" />{{ t("grid.resetFilterBuilder") }}</Button>
<Button variant="ghost" size="sm" class="h-6 shrink-0 px-1.5 text-xs text-muted-foreground hover:text-destructive" @click="emit('clear')"><Trash2 class="mr-1 h-3 w-3" />{{ t("grid.clearFilter") }}</Button>
<Button size="sm" class="h-6 shrink-0 px-2.5 text-xs" :disabled="disabled" @click="emit('apply')">{{ t("grid.applyFilter") }}</Button>
</footer>
<div
role="separator"
tabindex="0"
aria-orientation="horizontal"
:aria-label="t('grid.resizeFilterPanel')"
:aria-valuemin="DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN"
:aria-valuemax="DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX"
:aria-valuenow="panelHeight"
class="group absolute inset-x-0 -bottom-0.5 z-10 flex h-1.5 cursor-row-resize items-center justify-center outline-none"
@keydown="resizePanelByKeyboard"
@pointerdown="startResize"
>
<span class="h-px w-10 bg-border transition-colors group-hover:bg-primary group-focus:bg-primary" />
</div>
</section>
</template>
@@ -36,9 +36,11 @@ vi.mock("@lucide/vue", async () => {
Copy: icon,
Eye: icon,
EyeOff: icon,
GripVertical: icon,
Info: icon,
Pencil: icon,
Plus: icon,
RotateCcw: icon,
Trash2: icon,
};
});
@@ -90,6 +92,8 @@ import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.v
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
import DataGridCopyColumnNamesDialog from "@/components/grid/DataGridCopyColumnNamesDialog.vue";
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
import DataGridFilterWorkbench from "@/components/grid/DataGridFilterWorkbench.vue";
import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWorkbench.vue";
import DataGridPagination from "@/components/grid/DataGridPagination.vue";
import DataGridQueryControls from "@/components/grid/DataGridQueryControls.vue";
import DataGridSearchBar from "@/components/grid/DataGridSearchBar.vue";
@@ -435,6 +439,48 @@ describe("DataGridColumnHeader", () => {
});
describe("DataGridFilterBuilder", () => {
it("renders a compact text rule without framed form controls", async () => {
const updateRule = vi.fn();
const add = vi.fn();
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "account_id", mode: "equals", rawValue: "7", rawEndValue: "", conjunction: "AND" }],
columns: ["account_id"],
filteredColumns: ["account_id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
layout: "text",
showHeader: false,
showFooter: false,
onUpdateRule: updateRule,
onAdd: add,
});
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[18px_22px_var(--filter-builder-column-width)_92px_minmax(140px,1fr)_auto]"));
const enabledCheckbox = findOne(mounted.root, (node) => node.props.role === "checkbox");
const triggers = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectTrigger");
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
const addButton = findOne(mounted.root, (node) => node.props["aria-label"] === "grid.filterBuilderAddRule");
expect(enabledCheckbox.props["aria-checked"]).toBe(true);
expect(triggers.every((trigger) => String(trigger.props.class).includes("border-0"))).toBe(true);
expect(valueEditor.props.placeholder).toBe("grid.filterBuilderTextValue");
expect(String(ruleGrid.props.class)).toContain("border-b");
dispatch(enabledCheckbox, "click");
dispatch(addButton, "click");
dispatch(valueEditor, "keydown", { key: "Enter", shiftKey: true });
await mounted.setProps({
rules: [
{ id: "r1", columnName: "account_id", mode: "equals", rawValue: "7", rawEndValue: "", conjunction: "AND" },
{ id: "r2", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" },
],
});
await nextTick();
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);
});
it("opens the first empty rule column search on request", async () => {
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
@@ -464,7 +510,7 @@ describe("DataGridFilterBuilder", () => {
const selectValues = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectValue");
const items = findAll(mounted.root, (node) => node.props["data-stub"] === "SelectItem");
const filterBuilder = findOne(mounted.root, (node) => String(node.props.class).includes("w-fit max-w-full"));
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]"));
const ruleGrid = findOne(mounted.root, (node) => String(node.props.class).includes("grid-cols-[18px_var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]"));
const searchInput = findOne(mounted.root, (node) => node.type === "input" && node.props.placeholder === "grid.filterBuilderSearchColumns");
const valueEditor = findOne(mounted.root, (node) => node.props["data-filter-value-editor"] === "");
@@ -479,7 +525,7 @@ describe("DataGridFilterBuilder", () => {
expect(searchInput.props.placeholder).toBe("grid.filterBuilderSearchColumns");
expect(valueEditor.props.placeholder).toBe("grid.filterBuilderValue");
expect(filterBuilder.props.style).toEqual({ "--filter-builder-column-width": "178px", "--filter-builder-value-width": "178px" });
expect(String(ruleGrid.props.class)).toContain("grid-cols-[var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]");
expect(String(ruleGrid.props.class)).toContain("grid-cols-[18px_var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]");
expect(String(ruleGrid.props.class)).toContain("justify-start");
for (const trigger of triggers) {
expect(String(trigger.props.class)).toContain("w-full");
@@ -502,6 +548,31 @@ describe("DataGridFilterBuilder", () => {
expect(filterBuilder.props.style).toEqual({ "--filter-builder-column-width": "88px", "--filter-builder-value-width": "178px" });
});
it("renders reorder handles and supports keyboard condition moves", () => {
const move = vi.fn();
const mounted = mountComponent(DataGridFilterBuilder, {
rules: [
{ id: "r1", columnName: "id", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" },
{ id: "r2", columnName: "name", mode: "equals", rawValue: "Alice", rawEndValue: "", conjunction: "AND", disabled: true },
],
columns: ["id", "name"],
filteredColumns: ["id", "name"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onMove: move,
});
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[0].props["aria-label"]).toBe("grid.filterBuilderReorderRule");
const arrowDown = dispatch(handles[0], "keydown", { key: "ArrowDown" });
expect(arrowDown.defaultPrevented).toBe(true);
expect(move).toHaveBeenCalledWith("r1", 1);
dispatch(handles[1], "keydown", { key: "ArrowUp" });
expect(move).toHaveBeenLastCalledWith("r2", 0);
});
it("keeps search focus while navigating and selecting filtered columns", async () => {
const onUpdateRule = vi.fn();
const onAdd = vi.fn();
@@ -730,6 +801,7 @@ describe("DataGridQueryControls", () => {
compact: false,
leadingBorder: false,
filterBuilderOpen: false,
filterEditorView: "quick",
filterButtonActive: false,
filterButtonCount: 0,
hasLocalColumnFilters: false,
@@ -772,6 +844,7 @@ describe("DataGridQueryControls", () => {
compact: false,
leadingBorder: false,
filterBuilderOpen: false,
filterEditorView: "quick",
filterButtonActive: true,
filterButtonCount: 1,
hasLocalColumnFilters: false,
@@ -807,6 +880,7 @@ describe("DataGridQueryControls", () => {
compact: false,
leadingBorder: false,
filterBuilderOpen: true,
filterEditorView: "quick",
filterButtonActive: false,
filterButtonCount: 0,
hasLocalColumnFilters: false,
@@ -824,6 +898,10 @@ describe("DataGridQueryControls", () => {
expect(String(popoverContent.props.class)).toContain("w-fit");
expect(String(popoverContent.props.class)).toContain("max-w-[calc(100vw-16px)]");
expect(String(popoverContent.props.class)).toContain("max-h-[var(--reka-popover-content-available-height)]");
expect(String(popoverContent.props.class)).toContain("overflow-y-auto");
expect(popoverContent.props["data-filter-rules-scroll"]).toBe("");
expect(popoverContent.props["collision-padding"]).toBe(8);
});
it("keeps filter actions available in the popover", () => {
@@ -841,6 +919,7 @@ describe("DataGridQueryControls", () => {
compact: false,
leadingBorder: false,
filterBuilderOpen: true,
filterEditorView: "quick",
filterButtonActive: true,
filterButtonCount: 1,
hasLocalColumnFilters: false,
@@ -886,6 +965,205 @@ describe("DataGridQueryControls", () => {
expect(resetFilters).toHaveBeenCalledOnce();
expect(applyFilters).toHaveBeenCalledOnce();
});
it("keeps view selection out of the data grid controls", async () => {
const mounted = mountComponent(DataGridQueryControls, {
whereInput: "",
orderByInput: "",
columns: ["id"],
conditionColumns: ["id"],
historyScope: {},
canUseWhereSearch: true,
compact: false,
leadingBorder: false,
filterBuilderOpen: true,
filterEditorView: "quick",
filterButtonActive: false,
filterButtonCount: 0,
hasLocalColumnFilters: false,
localFilterCount: 0,
localFilterSummaries: [],
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
filteredColumns: ["id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
applyWhere: vi.fn(),
applyOrderBy: vi.fn(),
clearOrderBy: vi.fn(),
});
expect(hostText(mounted.root)).not.toContain("grid.filterQuickView");
expect(hostText(mounted.root)).not.toContain("grid.filterConditionView");
expect(findAll(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"))).toHaveLength(1);
await mounted.setProps({ filterEditorView: "conditions", filterBuilderOpen: false });
await nextTick();
expect(findOne(mounted.root, (node) => node.type === "textarea" && node.props.placeholder === "WHERE")).toBeTruthy();
expect(findAll(mounted.root, (node) => node.type === "button" && String(node.props.class).includes("-translate-x-1"))).toHaveLength(0);
});
});
describe("DataGridFilterWorkbench", () => {
it("scrolls to the newest rule when a condition is added", async () => {
const requestAnimationFrame = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
callback(0);
return 1;
});
const firstRule = { id: "r1", columnName: "id", mode: "equals" as const, rawValue: "1", rawEndValue: "", conjunction: "AND" as const };
const mounted = mountComponent(DataGridFilterWorkbench, {
sqlPreview: "WHERE id = 1",
rules: [firstRule],
columns: ["id", "name"],
filteredColumns: ["id", "name"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
});
const scroller = findOne(mounted.root, (node) => node.props["data-filter-rules-scroll"] === "") as any;
scroller.scrollTop = 0;
scroller.scrollHeight = 480;
await mounted.setProps({ rules: [firstRule, { ...firstRule, id: "r2", columnName: "name" }] });
await nextTick();
expect(scroller.scrollTop).toBe(480);
requestAnimationFrame.mockRestore();
});
it("does not auto-open field search in the conditions view", async () => {
const mounted = mountComponent(DataGridFilterWorkbench, {
sqlPreview: "",
rules: [{ id: "r1", columnName: "", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
columns: ["id", "name"],
filteredColumns: ["id", "name"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
});
await nextTick();
expect(findOne(mounted.root, (node) => node.props["data-stub"] === "Select").props.open).toBe(false);
});
it("exposes persistent condition editing, SQL preview, and explicit actions", async () => {
const ensureRule = vi.fn();
const addRule = vi.fn();
const apply = vi.fn();
const reset = vi.fn();
const clear = vi.fn();
const copySql = vi.fn();
const mounted = mountComponent(DataGridFilterWorkbench, {
sqlPreview: "WHERE (tenant_id = 7) AND (status = 'open')",
rules: [
{ id: "r1", columnName: "tenant_id", mode: "equals", rawValue: "7", rawEndValue: "", conjunction: "AND" },
{ id: "r2", columnName: "status", mode: "equals", rawValue: "open", rawEndValue: "", conjunction: "AND" },
],
columns: ["id", "tenant_id", "status"],
filteredColumns: ["id", "tenant_id", "status"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onEnsureRule: ensureRule,
onAddRule: addRule,
onApply: apply,
onReset: reset,
onClear: clear,
onCopySql: copySql,
});
await nextTick();
expect(ensureRule).toHaveBeenCalledOnce();
expect(hostText(mounted.root)).not.toContain("grid.filterQuickView");
expect(hostText(mounted.root)).not.toContain("grid.filterConditionView");
expect(hostText(mounted.root)).toContain("grid.clearFilter");
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);
dispatch(
findOne(mounted.root, (node) => node.props["aria-label"] === "grid.copyFilterSql"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.filterBuilderAddRule"),
"click",
);
const resetButton = findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.resetFilterBuilder");
expect(resetButton.props.variant).toBe("outline");
dispatch(resetButton, "click");
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.applyFilter"),
"click",
);
dispatch(
findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.clearFilter"),
"click",
);
expect(copySql).toHaveBeenCalledOnce();
expect(addRule).toHaveBeenCalledOnce();
expect(reset).toHaveBeenCalledOnce();
expect(apply).toHaveBeenCalledOnce();
expect(clear).toHaveBeenCalledOnce();
});
it("shows the empty preview state without enabling copy", () => {
const mounted = mountComponent(DataGridFilterWorkbench, {
sqlPreview: "",
rules: [],
columns: [],
filteredColumns: [],
modeOptions: [],
columnSearch: "",
});
expect(hostText(mounted.root)).toContain("grid.filterSqlPreviewEmpty");
expect(findOne(mounted.root, (node) => node.props["aria-label"] === "grid.copyFilterSql").props.disabled).toBe(true);
});
});
describe("DataGridTextFilterWorkbench", () => {
it("exposes a persisted keyboard-resizable text filter panel", async () => {
const ensureRule = vi.fn();
const updateHeight = vi.fn();
const addRule = vi.fn();
const mounted = mountComponent(DataGridTextFilterWorkbench, {
height: 168,
sqlPreview: "WHERE account_id = 7",
rules: [{ id: "r1", columnName: "account_id", mode: "equals", rawValue: "7", rawEndValue: "", conjunction: "AND" }],
columns: ["account_id"],
filteredColumns: ["account_id"],
modeOptions: [{ value: "equals", labelKey: "equals" }],
columnSearch: "",
onEnsureRule: ensureRule,
onAddRule: addRule,
"onUpdate:height": updateHeight,
});
await nextTick();
const panel = findOne(mounted.root, (node) => node.props["data-grid-text-filter-workbench"] === "");
const rulesArea = findOne(mounted.root, (node) => node.props["data-filter-rules-scroll"] === "") as any;
const resizeHandle = findOne(mounted.root, (node) => node.props.role === "separator");
const resetButton = findOne(mounted.root, (node) => node.type === "button" && hostText(node) === "grid.resetFilterBuilder");
expect(ensureRule).toHaveBeenCalledOnce();
expect(panel.props.style).toEqual({ height: "168px", maxHeight: "55vh" });
expect(resizeHandle.props["aria-valuenow"]).toBe(168);
const addTooltip = findOne(mounted.root, (node) => node.props["data-stub"] === "Tooltip");
expect(addTooltip.props.delayDuration ?? addTooltip.props["delay-duration"]).toBe(800);
expect(hostText(addTooltip)).toContain("Shift+Enter");
expect(resetButton.props.variant).toBe("outline");
rulesArea.focus = vi.fn();
dispatch(rulesArea, "pointerdown");
const addShortcut = dispatch(rulesArea, "keydown", { key: "Enter", shiftKey: true });
dispatch(rulesArea, "keydown", { key: "Enter", shiftKey: false });
expect(rulesArea.focus).toHaveBeenCalledOnce();
expect(addShortcut.defaultPrevented).toBe(true);
expect(addShortcut.propagationStopped).toBe(true);
expect(addRule).toHaveBeenCalledOnce();
dispatch(resizeHandle, "keydown", { key: "ArrowDown" });
await nextTick();
expect(updateHeight).toHaveBeenCalledWith(176);
});
});
describe("cell detail surfaces", () => {