feat(grid): add persistent SQL filter views

This commit is contained in:
zipg
2026-08-15 14:45:52 +08:00
committed by GitHub
parent 8bf4485a75
commit 9e7d80f467
27 changed files with 1428 additions and 107 deletions
@@ -38,6 +38,7 @@ import {
type InterfaceLayout,
type DisconnectTabHandlingMode,
type DataTabReuseMode,
type DataGridFilterEditorView,
type OpenTabsRestoreMode,
type SidebarObjectInfoMode,
type SqlSemanticDiagnosticsMode,
@@ -328,6 +329,8 @@ const editColorizeDataGridCellTypes = ref(settingsStore.editorSettings.colorizeD
const editShowIndexIndicatorsInHeader = ref(settingsStore.editorSettings.showIndexIndicatorsInHeader);
const editCompactColumnHeaderActions = ref(settingsStore.editorSettings.compactColumnHeaderActions);
const editDataGridQuickEntry = ref(settingsStore.editorSettings.dataGridQuickEntry);
const editDataGridFilterEditorView = ref<DataGridFilterEditorView>(settingsStore.editorSettings.dataGridFilterEditorView);
const editDataGridTextFilterPanelHeight = ref(settingsStore.editorSettings.dataGridTextFilterPanelHeight);
const editDataGridAutoTransposeSingleRow = ref(settingsStore.editorSettings.dataGridAutoTransposeSingleRow);
const editPageSize = ref(settingsStore.editorSettings.pageSize);
const editTableOpenPageSize = ref(settingsStore.editorSettings.tableOpenPageSize);
@@ -509,6 +512,8 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
showIndexIndicatorsInHeader: editShowIndexIndicatorsInHeader.value,
compactColumnHeaderActions: editCompactColumnHeaderActions.value,
dataGridQuickEntry: editDataGridQuickEntry.value,
dataGridFilterEditorView: editDataGridFilterEditorView.value,
dataGridTextFilterPanelHeight: editDataGridTextFilterPanelHeight.value,
dataGridAutoTransposeSingleRow: editDataGridAutoTransposeSingleRow.value,
flatteningMultiLineText: editFlatteningMultiLineText.value,
pageSize: editPageSize.value,
@@ -790,6 +795,8 @@ function syncEditorSettingsDraftFromStore() {
editShowIndexIndicatorsInHeader.value = settingsStore.editorSettings.showIndexIndicatorsInHeader;
editCompactColumnHeaderActions.value = settingsStore.editorSettings.compactColumnHeaderActions;
editDataGridQuickEntry.value = settingsStore.editorSettings.dataGridQuickEntry;
editDataGridFilterEditorView.value = settingsStore.editorSettings.dataGridFilterEditorView;
editDataGridTextFilterPanelHeight.value = settingsStore.editorSettings.dataGridTextFilterPanelHeight;
editDataGridAutoTransposeSingleRow.value = settingsStore.editorSettings.dataGridAutoTransposeSingleRow;
editFlatteningMultiLineText.value = settingsStore.editorSettings.flatteningMultiLineText;
editPageSize.value = settingsStore.editorSettings.pageSize;
@@ -1048,6 +1055,8 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editShowIndexIndicatorsInHeader.value = DEFAULT_EDITOR_SETTINGS.showIndexIndicatorsInHeader;
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
editDataGridFilterEditorView.value = DEFAULT_EDITOR_SETTINGS.dataGridFilterEditorView;
editDataGridTextFilterPanelHeight.value = DEFAULT_EDITOR_SETTINGS.dataGridTextFilterPanelHeight;
editDataGridAutoTransposeSingleRow.value = DEFAULT_EDITOR_SETTINGS.dataGridAutoTransposeSingleRow;
editFlatteningMultiLineText.value = DEFAULT_EDITOR_SETTINGS.flatteningMultiLineText;
editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize;
@@ -1120,6 +1129,8 @@ function resetAllDefaults() {
editShowIndexIndicatorsInHeader.value = DEFAULT_EDITOR_SETTINGS.showIndexIndicatorsInHeader;
editCompactColumnHeaderActions.value = DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions;
editDataGridQuickEntry.value = DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry;
editDataGridFilterEditorView.value = DEFAULT_EDITOR_SETTINGS.dataGridFilterEditorView;
editDataGridTextFilterPanelHeight.value = DEFAULT_EDITOR_SETTINGS.dataGridTextFilterPanelHeight;
editDataGridAutoTransposeSingleRow.value = DEFAULT_EDITOR_SETTINGS.dataGridAutoTransposeSingleRow;
editFlatteningMultiLineText.value = DEFAULT_EDITOR_SETTINGS.flatteningMultiLineText;
editPageSize.value = DEFAULT_EDITOR_SETTINGS.pageSize;
@@ -4881,6 +4892,26 @@ onUnmounted(() => {
<div class="text-sm font-medium text-muted-foreground">
{{ t("settings.dataGridDisplay") }}
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="data-grid-filter-view">
{{ t("settings.dataGridFilterView") }}
</Label>
<p class="text-xs text-muted-foreground">
{{ t("settings.dataGridFilterViewDescription") }}
</p>
</div>
<Select v-model="editDataGridFilterEditorView">
<SelectTrigger id="data-grid-filter-view" class="h-8 w-44 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="quick">{{ t("grid.filterQuickView") }}</SelectItem>
<SelectItem value="conditions">{{ t("grid.filterConditionView") }}</SelectItem>
<SelectItem value="text">{{ t("grid.filterTextView") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="table-open-page-size">
+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", () => {
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildDataGridStructuredWhere, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
import { describe, expect, it, vi } from "vitest";
import { buildDataGridStructuredWhere, createDataGridFilterConditionCache, moveDataGridStructuredFilterRule, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
describe("useDataGridFilterBuilder", () => {
it("searches columns by camel-case initials and any-position text", () => {
@@ -52,4 +52,52 @@ describe("useDataGridFilterBuilder", () => {
]),
).toBe("((a) AND (b)) OR (c)");
});
it("moves complete and disabled rules while preserving their condition data", async () => {
let nextId = 0;
const builder = useDataGridFilterBuilder({
columns: ["a", "b", "c"],
createId: () => `rule-${++nextId}`,
isComplete: () => true,
buildCondition: async (rule) => `${rule.columnName} = ${rule.rawValue}`,
});
builder.ensureRule();
builder.updateRule("rule-1", { columnName: "a", rawValue: "1" });
builder.addRule();
builder.updateRule("rule-2", { columnName: "b", rawValue: "2", disabled: true });
builder.addRule();
builder.updateRule("rule-3", { columnName: "c", rawValue: "3" });
builder.moveRule("rule-3", 0);
expect(builder.rules.value.map((rule) => rule.id)).toEqual(["rule-3", "rule-1", "rule-2"]);
expect(builder.rules.value[2]).toMatchObject({ columnName: "b", rawValue: "2", disabled: true });
expect(await builder.buildWhere()).toBe("(c = 3) AND (a = 1)");
});
it("clamps rule moves and ignores unknown rule ids", () => {
const rule = (id: string): DataGridStructuredFilterRule => ({ id, columnName: id, mode: "equals", rawValue: id, rawEndValue: "", conjunction: "AND" });
const rules = [rule("a"), rule("b"), rule("c")];
expect(moveDataGridStructuredFilterRule(rules, "a", 99).map((item) => item.id)).toEqual(["b", "c", "a"]);
expect(moveDataGridStructuredFilterRule(rules, "c", -2).map((item) => item.id)).toEqual(["c", "a", "b"]);
expect(moveDataGridStructuredFilterRule(rules, "missing", 1)).toEqual(rules);
});
it("reuses unchanged rule conditions and drops removed rules", async () => {
const cache = createDataGridFilterConditionCache();
const buildFirst = vi.fn(async () => "id = 1");
const buildChanged = vi.fn(async () => "id = 2");
await expect(cache.resolve("rule-1", "id:1", buildFirst)).resolves.toBe("id = 1");
await expect(cache.resolve("rule-1", "id:1", buildFirst)).resolves.toBe("id = 1");
expect(buildFirst).toHaveBeenCalledOnce();
await expect(cache.resolve("rule-1", "id:2", buildChanged)).resolves.toBe("id = 2");
expect(buildChanged).toHaveBeenCalledOnce();
cache.retain([]);
await expect(cache.resolve("rule-1", "id:2", buildChanged)).resolves.toBe("id = 2");
expect(buildChanged).toHaveBeenCalledTimes(2);
});
});
@@ -29,6 +29,45 @@ export function buildDataGridStructuredWhere(items: Array<{ rule: DataGridStruct
return result;
}
export function moveDataGridStructuredFilterRule(rules: readonly DataGridStructuredFilterRule[], ruleId: string, targetIndex: number): DataGridStructuredFilterRule[] {
const sourceIndex = rules.findIndex((rule) => rule.id === ruleId);
if (sourceIndex < 0 || rules.length < 2) return [...rules];
const nextIndex = Math.min(rules.length - 1, Math.max(0, Math.trunc(targetIndex)));
if (sourceIndex === nextIndex) return [...rules];
const nextRules = [...rules];
const [rule] = nextRules.splice(sourceIndex, 1);
nextRules.splice(nextIndex, 0, rule);
return nextRules;
}
export function createDataGridFilterConditionCache() {
const entries = new Map<string, { signature: string; condition: Promise<string | null> }>();
function resolve(ruleId: string, signature: string, build: () => Promise<string | null>): Promise<string | null> {
const cached = entries.get(ruleId);
if (cached?.signature === signature) return cached.condition;
let condition: Promise<string | null>;
condition = Promise.resolve()
.then(build)
.catch((error) => {
if (entries.get(ruleId)?.condition === condition) entries.delete(ruleId);
throw error;
});
entries.set(ruleId, { signature, condition });
return condition;
}
function retain(ruleIds: readonly string[]) {
const retained = new Set(ruleIds);
for (const ruleId of entries.keys()) {
if (!retained.has(ruleId)) entries.delete(ruleId);
}
}
return { resolve, retain };
}
export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOptions) {
const rules = ref<DataGridStructuredFilterRule[]>([]);
const open = ref(false);
@@ -63,6 +102,9 @@ export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOption
return next;
});
}
function moveRule(id: string, targetIndex: number) {
rules.value = moveDataGridStructuredFilterRule(rules.value, id, targetIndex);
}
function reset() {
appliedWhereInput.value = "";
rules.value = toValue(options.columns).length ? [defaultRule()] : [];
@@ -90,5 +132,5 @@ export function useDataGridFilterBuilder(options: UseDataGridFilterBuilderOption
},
);
return { rules, open, columnSearch, appliedWhereInput, filteredColumns, activeCount, defaultRule, ensureRule, addRule, removeRule, updateRule, reset, buildWhere, apply };
return { rules, open, columnSearch, appliedWhereInput, filteredColumns, activeCount, defaultRule, ensureRule, addRule, removeRule, updateRule, moveRule, reset, buildWhere, apply };
}
+18
View File
@@ -1607,12 +1607,28 @@ export default {
filterBuilder: "Filters",
filterBuilderTitle: "Filters",
filterBuilderHint: "Build point-and-click conditions and combine them with the WHERE input.",
filterView: "Filter view",
filterQuickView: "Quick view",
filterConditionView: "Conditions view",
filterTextView: "Text view",
resizeFilterPanel: "Resize filter panel",
filterConditionPanel: "Filter conditions",
filterManualWhere: "Manual WHERE",
filterSqlPreview: "SQL condition preview",
filterSqlPreviewEmpty: "No valid filter condition yet",
filterSqlPreviewReady: "Condition ready; apply to refresh results",
copyFilterSql: "Copy SQL condition",
filterSqlCopied: "SQL condition copied",
filterBuilderSummary: "{count} rules",
filterBuilderAddRule: "Add rule",
filterBuilderEnableRule: "Enable rule",
filterBuilderDisableRule: "Disable rule",
filterBuilderReorderRule: "Drag or use arrow keys to reorder condition",
filterBuilderColumn: "Column",
filterBuilderSearchColumns: "Search columns...",
filterBuilderNoMatchingColumns: "No matching columns",
filterBuilderValue: "Value",
filterBuilderTextValue: "<?>",
filterBuilderValueTypeAuto: "Auto",
filterBuilderValueTypeString: "String",
filterBuilderValueTypeNumber: "Number",
@@ -5733,6 +5749,8 @@ export default {
debugLogsClear: "Clear logs",
tableStructureSection: "Table Structure",
dataGridDisplay: "Data grid display",
dataGridFilterView: "Table filter view",
dataGridFilterViewDescription: "Use a filter popover, a fixed conditions panel, or a compact text conditions panel.",
tableOpenPageSize: "Default rows per page when opening tables",
tableOpenPageSizeDescription: "Rows loaded per page when opening a new table. Changing rows per page in a table updates this default for future table tabs.",
queryPageSize: "Default query rows per page",
+18
View File
@@ -1492,10 +1492,26 @@ export default withEnglishFallback({
filterBuilder: "Filtros",
filterBuilderTitle: "Filtros",
filterBuilderHint: "Crea condiciones visuales y combínalas con el campo WHERE.",
filterView: "Vista de filtros",
filterQuickView: "Vista rápida",
filterConditionView: "Vista de condiciones",
filterTextView: "Vista de texto",
resizeFilterPanel: "Cambiar altura del panel de filtros",
filterConditionPanel: "Condiciones de filtro",
filterManualWhere: "WHERE manual",
filterSqlPreview: "Vista previa de la condición SQL",
filterSqlPreviewEmpty: "Aún no hay una condición de filtro válida",
filterSqlPreviewReady: "Condición lista; aplícala para actualizar los resultados",
copyFilterSql: "Copiar condición SQL",
filterSqlCopied: "Condición SQL copiada",
filterBuilderSummary: "{count} reglas",
filterBuilderAddRule: "Agregar regla",
filterBuilderEnableRule: "Activar regla",
filterBuilderDisableRule: "Desactivar regla",
filterBuilderReorderRule: "Arrastra o usa las flechas para reordenar la condición",
filterBuilderColumn: "Columna",
filterBuilderValue: "Valor",
filterBuilderTextValue: "<?>",
filterBuilderValueTypeAuto: "Automático",
filterBuilderValueTypeString: "Cadena",
filterBuilderValueTypeNumber: "Número",
@@ -5482,6 +5498,8 @@ export default withEnglishFallback({
debugLogsClear: "Borrar logs",
tableStructureSection: "Estructura de tabla",
dataGridDisplay: "Visualización de la tabla",
dataGridFilterView: "Vista de filtro de tabla",
dataGridFilterViewDescription: "Usa un filtro emergente, un panel de condiciones fijo o un panel de texto compacto.",
tableOpenPageSize: "Filas predeterminadas por página al abrir tablas",
tableOpenPageSizeDescription: "Filas cargadas por página al abrir una tabla nueva. Cambiar las filas por página actualiza este valor para futuras pestañas de tablas.",
queryPageSize: "Filas predeterminadas por página de consulta",
+18
View File
@@ -1490,10 +1490,26 @@ export default withEnglishFallback({
filterBuilder: "Filtri",
filterBuilderTitle: "Filtri",
filterBuilderHint: "Crea condizioni visive e combinale con l'input WHERE.",
filterView: "Vista filtri",
filterQuickView: "Vista rapida",
filterConditionView: "Vista condizioni",
filterTextView: "Vista testo",
resizeFilterPanel: "Ridimensiona pannello filtri",
filterConditionPanel: "Condizioni filtro",
filterManualWhere: "WHERE manuale",
filterSqlPreview: "Anteprima condizione SQL",
filterSqlPreviewEmpty: "Nessuna condizione filtro valida",
filterSqlPreviewReady: "Condizione pronta; applicala per aggiornare i risultati",
copyFilterSql: "Copia condizione SQL",
filterSqlCopied: "Condizione SQL copiata",
filterBuilderSummary: "{count} regole",
filterBuilderAddRule: "Aggiungi regola",
filterBuilderEnableRule: "Abilita regola",
filterBuilderDisableRule: "Disabilita regola",
filterBuilderReorderRule: "Trascina o usa i tasti freccia per riordinare la condizione",
filterBuilderColumn: "Colonna",
filterBuilderValue: "Valore",
filterBuilderTextValue: "<?>",
filterBuilderValueTypeAuto: "Automatico",
filterBuilderValueTypeString: "Stringa",
filterBuilderValueTypeNumber: "Numero",
@@ -5482,6 +5498,8 @@ export default withEnglishFallback({
debugLogsClear: "Cancella log",
tableStructureSection: "Struttura tabella",
dataGridDisplay: "Visualizzazione griglia dati",
dataGridFilterView: "Vista filtro tabella",
dataGridFilterViewDescription: "Usa un filtro popup, un pannello condizioni fisso o un pannello di testo compatto.",
tableOpenPageSize: "Righe predefinite per pagina all'apertura delle tabelle",
tableOpenPageSizeDescription: "Righe caricate per pagina quando si apre una nuova tabella. La modifica delle righe per pagina aggiorna questo valore per le future schede tabella.",
queryPageSize: "Righe predefinite per pagina di query",
+18
View File
@@ -1508,10 +1508,26 @@ export default withEnglishFallback({
filterBuilder: "フィルター",
filterBuilderTitle: "フィルター",
filterBuilderHint: "ポイントアンドクリックで条件を作成し、WHERE入力と組み合わせます。",
filterView: "フィルタービュー",
filterQuickView: "クイックビュー",
filterConditionView: "条件ビュー",
filterTextView: "テキストビュー",
resizeFilterPanel: "フィルターパネルの高さを調整",
filterConditionPanel: "フィルター条件",
filterManualWhere: "手動 WHERE",
filterSqlPreview: "SQL 条件プレビュー",
filterSqlPreviewEmpty: "有効なフィルター条件はまだありません",
filterSqlPreviewReady: "条件の準備完了。適用すると結果を更新します",
copyFilterSql: "SQL 条件をコピー",
filterSqlCopied: "SQL 条件をコピーしました",
filterBuilderSummary: "{count}件のルール",
filterBuilderAddRule: "ルールを追加",
filterBuilderEnableRule: "条件を有効化",
filterBuilderDisableRule: "条件を無効化",
filterBuilderReorderRule: "ドラッグまたは矢印キーで条件を並べ替え",
filterBuilderColumn: "列",
filterBuilderValue: "値",
filterBuilderTextValue: "<?>",
filterBuilderValueTypeAuto: "自動",
filterBuilderValueTypeString: "文字列",
filterBuilderValueTypeNumber: "数値",
@@ -5517,6 +5533,8 @@ export default withEnglishFallback({
debugLogsClear: "ログをクリア",
tableStructureSection: "テーブル構造",
dataGridDisplay: "データグリッド表示",
dataGridFilterView: "テーブルフィルタービュー",
dataGridFilterViewDescription: "フィルターポップオーバー、固定条件パネル、またはコンパクトなテキスト条件パネルを使用します。",
tableOpenPageSize: "テーブルを開くときのデフォルト行数",
tableOpenPageSizeDescription: "新しいテーブルを開くときに 1 ページあたり読み込む行数です。テーブルで行数を変更すると、今後のテーブルタブのデフォルトも更新されます。",
queryPageSize: "クエリ結果のデフォルト行数",
+18
View File
@@ -1487,12 +1487,28 @@ export default withEnglishFallback({
filterBuilder: "필터",
filterBuilderTitle: "필터",
filterBuilderHint: "클릭 기반 조건을 만들고 WHERE 입력과 결합하세요.",
filterView: "필터 보기",
filterQuickView: "빠른 보기",
filterConditionView: "조건 보기",
filterTextView: "텍스트 보기",
resizeFilterPanel: "필터 패널 높이 조절",
filterConditionPanel: "필터 조건",
filterManualWhere: "수동 WHERE",
filterSqlPreview: "SQL 조건 미리보기",
filterSqlPreviewEmpty: "아직 유효한 필터 조건이 없습니다",
filterSqlPreviewReady: "조건이 준비되었습니다. 적용하면 결과가 새로 고쳐집니다",
copyFilterSql: "SQL 조건 복사",
filterSqlCopied: "SQL 조건을 복사했습니다",
filterBuilderSummary: "{count}개 규칙",
filterBuilderAddRule: "규칙 추가",
filterBuilderEnableRule: "조건 활성화",
filterBuilderDisableRule: "조건 비활성화",
filterBuilderReorderRule: "드래그하거나 방향키로 조건 순서 변경",
filterBuilderColumn: "컬럼",
filterBuilderSearchColumns: "컬럼 검색...",
filterBuilderNoMatchingColumns: "일치하는 컬럼이 없습니다",
filterBuilderValue: "값",
filterBuilderTextValue: "<?>",
filterBuilderValueTypeAuto: "자동",
filterBuilderValueTypeString: "문자열",
filterBuilderValueTypeNumber: "숫자",
@@ -5220,6 +5236,8 @@ export default withEnglishFallback({
debugLogsClear: "로그 지우기",
tableStructureSection: "테이블 구조",
dataGridDisplay: "데이터 그리드 표시",
dataGridFilterView: "테이블 필터 보기",
dataGridFilterViewDescription: "필터 팝오버, 고정 조건 패널 또는 간결한 텍스트 조건 패널을 사용합니다.",
tableOpenPageSize: "테이블을 열 때 기본 페이지당 행 수",
tableOpenPageSizeDescription: "새 테이블을 열 때 페이지당 로드되는 행 수입니다. 테이블에서 페이지당 행을 변경하면 향후 테이블 탭의 기본값이 업데이트됩니다.",
queryPageSize: "쿼리 결과 기본 페이지당 행 수",
+18
View File
@@ -1492,10 +1492,26 @@ export default withEnglishFallback({
filterBuilder: "Filtros",
filterBuilderTitle: "Filtros",
filterBuilderHint: "Crie condições com cliques e combine-as com a entrada WHERE.",
filterView: "Visualização de filtros",
filterQuickView: "Visualização rápida",
filterConditionView: "Visualização de condições",
filterTextView: "Visualização de texto",
resizeFilterPanel: "Redimensionar painel de filtros",
filterConditionPanel: "Condições de filtro",
filterManualWhere: "WHERE manual",
filterSqlPreview: "Prévia da condição SQL",
filterSqlPreviewEmpty: "Ainda não há uma condição de filtro válida",
filterSqlPreviewReady: "Condição pronta; aplique para atualizar os resultados",
copyFilterSql: "Copiar condição SQL",
filterSqlCopied: "Condição SQL copiada",
filterBuilderSummary: "{count} regras",
filterBuilderAddRule: "Adicionar regra",
filterBuilderEnableRule: "Ativar regra",
filterBuilderDisableRule: "Desativar regra",
filterBuilderReorderRule: "Arraste ou use as setas para reordenar a condição",
filterBuilderColumn: "Coluna",
filterBuilderValue: "Valor",
filterBuilderTextValue: "<?>",
filterBuilderValueTypeAuto: "Automático",
filterBuilderValueTypeString: "Texto",
filterBuilderValueTypeNumber: "Número",
@@ -5484,6 +5500,8 @@ export default withEnglishFallback({
debugLogsClear: "Limpar logs",
tableStructureSection: "Estrutura da tabela",
dataGridDisplay: "Exibição da grade de dados",
dataGridFilterView: "Visualização de filtro da tabela",
dataGridFilterViewDescription: "Use um filtro pop-up, um painel de condições fixo ou um painel de texto compacto.",
tableOpenPageSize: "Linhas padrão por página ao abrir tabelas",
tableOpenPageSizeDescription: "Linhas carregadas por página ao abrir uma nova tabela. Alterar as linhas por página atualiza esse padrão para futuras abas de tabelas.",
queryPageSize: "Linhas padrão por página de consulta",
+18
View File
@@ -1532,12 +1532,28 @@ export default withEnglishFallback({
filterBuilder: "筛选器",
filterBuilderTitle: "筛选器",
filterBuilderHint: "点选筛选条件,并与手写 WHERE 一起生效。",
filterView: "筛选视图",
filterQuickView: "快捷视图",
filterConditionView: "条件视图",
filterTextView: "文本视图",
resizeFilterPanel: "调整筛选面板高度",
filterConditionPanel: "筛选条件",
filterManualWhere: "手工 WHERE",
filterSqlPreview: "SQL 条件预览",
filterSqlPreviewEmpty: "尚未生成有效筛选条件",
filterSqlPreviewReady: "条件已就绪,应用后刷新结果",
copyFilterSql: "复制 SQL 条件",
filterSqlCopied: "SQL 条件已复制",
filterBuilderSummary: "{count} 条规则",
filterBuilderAddRule: "新增条件",
filterBuilderEnableRule: "启用条件",
filterBuilderDisableRule: "禁用条件",
filterBuilderReorderRule: "拖拽或使用方向键排序条件",
filterBuilderColumn: "字段",
filterBuilderSearchColumns: "搜索字段...",
filterBuilderNoMatchingColumns: "没有匹配的字段",
filterBuilderValue: "值",
filterBuilderTextValue: "<?>",
filterBuilderValueTypeAuto: "自动",
filterBuilderValueTypeString: "字符串",
filterBuilderValueTypeNumber: "数字",
@@ -5720,6 +5736,8 @@ export default withEnglishFallback({
debugLogsClear: "清空日志",
tableStructureSection: "表结构",
dataGridDisplay: "数据表格显示",
dataGridFilterView: "表格筛选视图",
dataGridFilterViewDescription: "选择弹出式快捷筛选、固定条件面板或紧凑文本条件面板。",
tableOpenPageSize: "打开表默认每页行数",
tableOpenPageSizeDescription: "新打开数据表时每页加载的行数。在表数据页修改每页行数后,也会更新后续新建表标签页的默认值。",
queryPageSize: "查询结果默认每页行数",
+18
View File
@@ -1491,10 +1491,26 @@ export default withEnglishFallback({
filterBuilder: "篩選器",
filterBuilderTitle: "篩選器",
filterBuilderHint: "點選篩選條件,並與手寫 WHERE 一起生效。",
filterView: "篩選檢視",
filterQuickView: "快速檢視",
filterConditionView: "條件檢視",
filterTextView: "文字檢視",
resizeFilterPanel: "調整篩選面板高度",
filterConditionPanel: "篩選條件",
filterManualWhere: "手動 WHERE",
filterSqlPreview: "SQL 條件預覽",
filterSqlPreviewEmpty: "尚未產生有效的篩選條件",
filterSqlPreviewReady: "條件已就緒,套用後重新整理結果",
copyFilterSql: "複製 SQL 條件",
filterSqlCopied: "SQL 條件已複製",
filterBuilderSummary: "{count} 條規則",
filterBuilderAddRule: "新增條件",
filterBuilderEnableRule: "啟用條件",
filterBuilderDisableRule: "停用條件",
filterBuilderReorderRule: "拖曳或使用方向鍵排序條件",
filterBuilderColumn: "欄位",
filterBuilderValue: "值",
filterBuilderTextValue: "<?>",
filterBuilderValueTypeAuto: "自動",
filterBuilderValueTypeString: "字串",
filterBuilderValueTypeNumber: "數字",
@@ -4819,6 +4835,8 @@ export default withEnglishFallback({
debugLogsClear: "清空日誌",
tableStructureSection: "表結構",
dataGridDisplay: "資料表格顯示",
dataGridFilterView: "表格篩選檢視",
dataGridFilterViewDescription: "選擇彈出式快速篩選、固定條件面板或精簡文字條件面板。",
tableOpenPageSize: "開啟資料表預設每頁列數",
tableOpenPageSizeDescription: "新開啟資料表時每頁載入的列數。在資料表頁面調整每頁列數後,也會更新後續新建資料表分頁的預設值。",
queryPageSize: "查詢結果預設每頁列數",
@@ -1,24 +1,39 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { loadDataGridStructuredFilterState, saveDataGridStructuredFilterState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
describe("data grid filter builder persistence", () => {
it("isolates scopes and returns cloned state", () => {
saveDataGridStructuredFilterState("cache", {
scopeKey: "scope-a",
manualWhereInput: "status = 1",
rules: [{ id: "rule-1", columnName: "status", mode: "equals", rawValue: "1", rawEndValue: "", conjunction: "AND" }],
appliedWhereInput: '"status" = 1',
serverColumnFilters: { 0: { condition: '"status" = 1', keys: ["1"], labels: ["1"] } },
describe("data grid structured filter persistence", () => {
const cacheKey = "issue-436-filter-view";
const scopeKey = "mysql\0demo\0users";
beforeEach(() => {
saveDataGridStructuredFilterState(cacheKey, {
scopeKey,
manualWhereInput: "tenant_id = 7",
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "open", rawEndValue: "", conjunction: "AND" }],
appliedWhereInput: "status = 'open'",
serverColumnFilters: {},
});
});
expect(loadDataGridStructuredFilterState("cache", "scope-b")).toBeUndefined();
const restored = loadDataGridStructuredFilterState("cache", "scope-a")!;
restored.rules[0].rawValue = "2";
restored.serverColumnFilters[0].keys.push("2");
it("restores the filter rules and manual condition", () => {
expect(loadDataGridStructuredFilterState(cacheKey, scopeKey)).toMatchObject({
manualWhereInput: "tenant_id = 7",
rules: [{ columnName: "status", rawValue: "open" }],
});
});
expect(loadDataGridStructuredFilterState("cache", "scope-a")).toMatchObject({
rules: [{ rawValue: "1" }],
serverColumnFilters: { 0: { keys: ["1"] } },
it("does not leak filters into another table scope", () => {
expect(loadDataGridStructuredFilterState(cacheKey, `${scopeKey}\0archive`)).toBeUndefined();
});
it("returns cloned state without mutating the cache", () => {
const restored = loadDataGridStructuredFilterState(cacheKey, scopeKey)!;
restored.rules[0].rawValue = "closed";
restored.serverColumnFilters[0] = { condition: "\"status\" = 'closed'", keys: ["closed"], labels: ["closed"] };
expect(loadDataGridStructuredFilterState(cacheKey, scopeKey)).toMatchObject({
rules: [{ rawValue: "open" }],
serverColumnFilters: {},
});
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { resolveDataGridFilterRuleDropPlacement } from "@/lib/dataGrid/dataGridFilterRuleDrag";
const bounds = [
{ id: "r1", top: 100, bottom: 130 },
{ id: "r2", top: 130, bottom: 160 },
{ id: "r3", top: 160, bottom: 190 },
{ id: "r4", top: 190, bottom: 220 },
];
describe("resolveDataGridFilterRuleDropPlacement", () => {
it("keeps the first and last insertion targets active beyond the outer rows", () => {
expect(resolveDataGridFilterRuleDropPlacement(["r1", "r2", "r3", "r4"], "r3", bounds, 40)).toEqual({ ruleId: "r1", position: "before", targetIndex: 0 });
expect(resolveDataGridFilterRuleDropPlacement(["r1", "r2", "r3", "r4"], "r2", bounds, 280)).toEqual({ ruleId: "r4", position: "after", targetIndex: 3 });
});
it("calculates the final index after removing the dragged rule", () => {
expect(resolveDataGridFilterRuleDropPlacement(["r1", "r2", "r3", "r4"], "r1", bounds, 165)).toEqual({ ruleId: "r3", position: "before", targetIndex: 1 });
expect(resolveDataGridFilterRuleDropPlacement(["r1", "r2", "r3", "r4"], "r4", bounds, 140)).toEqual({ ruleId: "r2", position: "before", targetIndex: 1 });
});
});
@@ -0,0 +1,27 @@
export type DataGridFilterRuleDropPlacement = {
ruleId: string;
position: "before" | "after";
targetIndex: number;
};
export type DataGridFilterRuleBounds = {
id: string;
top: number;
bottom: number;
};
export function resolveDataGridFilterRuleDropPlacement(ruleIds: readonly string[], draggingRuleId: string, rowBounds: readonly DataGridFilterRuleBounds[], clientY: number): DataGridFilterRuleDropPlacement | undefined {
const remainingRuleIds = ruleIds.filter((id) => id !== draggingRuleId);
const boundsById = new Map(rowBounds.map((bounds) => [bounds.id, bounds]));
const boundedRules = remainingRuleIds.flatMap((id, targetIndex) => {
const bounds = boundsById.get(id);
return bounds ? [{ id, targetIndex, bounds }] : [];
});
if (!boundedRules.length) return undefined;
const nextRule = boundedRules.find(({ bounds }) => clientY < bounds.top + (bounds.bottom - bounds.top) / 2);
if (nextRule) return { ruleId: nextRule.id, position: "before", targetIndex: nextRule.targetIndex };
const lastRule = boundedRules[boundedRules.length - 1];
return { ruleId: lastRule.id, position: "after", targetIndex: remainingRuleIds.length };
}
@@ -0,0 +1,8 @@
export const DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN = 96;
export const DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX = 420;
export const DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_DEFAULT = 168;
export function normalizeDataGridTextFilterPanelHeight(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) return DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_DEFAULT;
return Math.min(DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX, Math.max(DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN, Math.round(value)));
}
@@ -69,6 +69,11 @@ describe("EDITOR_SETTINGS_DRAFT_KEYS", () => {
expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("colorizeDataGridCellTypes");
});
it("includes the data grid filter view", () => {
expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("dataGridFilterEditorView");
expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("dataGridTextFilterPanelHeight");
});
it("includes completionTriggerMode", () => {
expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("completionTriggerMode");
});
@@ -124,6 +129,12 @@ describe("editorSettingsDraftFromSettings", () => {
expect(editorSettingsDraftFromSettings(makeSettings({ colorizeDataGridCellTypes: false })).colorizeDataGridCellTypes).toBe(false);
});
it("maps the data grid filter view from settings", () => {
const draft = editorSettingsDraftFromSettings(makeSettings({ dataGridFilterEditorView: "text", dataGridTextFilterPanelHeight: 224 }));
expect(draft.dataGridFilterEditorView).toBe("text");
expect(draft.dataGridTextFilterPanelHeight).toBe(224);
});
it("preserves the table-open default for legacy settings", () => {
const settings = makeSettings();
delete (settings as Partial<EditorSettings>).tableOpenPageSize;
@@ -137,6 +137,7 @@ describe("settings search", () => {
{ titleKey: "settings.confirmDangerousSqlExecution", category: "editor", targetId: "editor" },
{ titleKey: "settings.continueOnErrorOnBatch", category: "editor", targetId: "editor" },
{ titleKey: "settings.dataGridQuickEntry", category: "data", targetId: "data" },
{ titleKey: "settings.dataGridFilterView", category: "data", targetId: "data" },
{ titleKey: "settings.colorizeDataGridCellTypes", category: "data", targetId: "data" },
{ titleKey: "transfer.dataTransfer", category: "appearance", targetId: "appearance" },
{ titleKey: "toolbar.driverManager", category: "appearance", targetId: "appearance" },
@@ -37,6 +37,8 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"showIndexIndicatorsInHeader",
"compactColumnHeaderActions",
"dataGridQuickEntry",
"dataGridFilterEditorView",
"dataGridTextFilterPanelHeight",
"dataGridAutoTransposeSingleRow",
"pageSize",
"tableOpenPageSize",
@@ -185,6 +185,7 @@ export const SETTINGS_SEARCH_DEFINITIONS: readonly SettingsSearchDefinition[] =
{ id: "data-grid-infinite-scroll", category: "data", titleKey: "settings.infiniteScroll", descriptionKey: "settings.infiniteScrollDescription", targetId: "data" },
{ id: "data-grid-auto-transpose", category: "data", titleKey: "settings.dataGridAutoTransposeSingleRow", descriptionKey: "settings.dataGridAutoTransposeSingleRowDescription", targetId: "data" },
{ id: "data-grid-quick-entry", category: "data", titleKey: "settings.dataGridQuickEntry", descriptionKey: "settings.dataGridQuickEntryDescription", targetId: "data" },
{ id: "data-grid-filter-view", category: "data", titleKey: "settings.dataGridFilterView", descriptionKey: "settings.dataGridFilterViewDescription", targetId: "data" },
{ id: "data-grid-flattening-multi-line", category: "data", titleKey: "settings.flatteningMultiLineText", descriptionKey: "settings.flatteningMultiLineTextDescription", targetId: "data" },
{ id: "appearance-toolbar", category: "appearance", titleKey: "settings.toolbarTitle", descriptionKey: "settings.toolbarHiddenHint", targetId: "appearance" },
{ id: "appearance-exclusive-sidebar-panels", category: "appearance", titleKey: "settings.exclusiveRightSidebarPanels", descriptionKey: "settings.exclusiveRightSidebarPanelsDescription", targetId: "appearance" },
+14
View File
@@ -13,6 +13,20 @@ describe("normalizeEditorSettings", () => {
expect(normalizeEditorSettings({ sqlVariableSubstitutionEnabled: null } as any).sqlVariableSubstitutionEnabled).toBe(true);
});
it("keeps the quick filter view by default and preserves fixed filter views", () => {
expect(normalizeEditorSettings({}).dataGridFilterEditorView).toBe("quick");
expect(normalizeEditorSettings({ dataGridFilterEditorView: "conditions" }).dataGridFilterEditorView).toBe("conditions");
expect(normalizeEditorSettings({ dataGridFilterEditorView: "text" }).dataGridFilterEditorView).toBe("text");
expect(normalizeEditorSettings({ dataGridFilterEditorView: "invalid" } as any).dataGridFilterEditorView).toBe("quick");
});
it("defaults and bounds the persisted text filter panel height", () => {
expect(normalizeEditorSettings({}).dataGridTextFilterPanelHeight).toBe(168);
expect(normalizeEditorSettings({ dataGridTextFilterPanelHeight: 236.4 }).dataGridTextFilterPanelHeight).toBe(236);
expect(normalizeEditorSettings({ dataGridTextFilterPanelHeight: 20 }).dataGridTextFilterPanelHeight).toBe(96);
expect(normalizeEditorSettings({ dataGridTextFilterPanelHeight: 900 }).dataGridTextFilterPanelHeight).toBe(420);
});
it("keeps data type colors disabled by default and preserves an explicit opt-in", () => {
expect(normalizeEditorSettings({}).colorizeDataGridCellTypes).toBe(false);
expect(normalizeEditorSettings({ colorizeDataGridCellTypes: true }).colorizeDataGridCellTypes).toBe(true);
+14
View File
@@ -7,6 +7,7 @@ import { setDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
import { type ColumnFormatterConfig, type CustomColumnFormatterConfig, normalizeColumnFormatter, normalizeCustomColumnFormatter, normalizeGlobalDateTimePattern } from "@/lib/dataGrid/columnFormatter";
import { type DataGridCopyPreference, type DataGridExtractorOptions, DEFAULT_DATA_GRID_EXTRACTOR_OPTIONS, normalizeDataGridCopyPreference, normalizeDataGridExtractorOptions } from "@/lib/dataGrid/dataGridCopyExtractor";
import { DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_DEFAULT, normalizeDataGridTextFilterPanelHeight } from "@/lib/dataGrid/dataGridTextFilterPanel";
import { normalizeResultPageSize } from "@/lib/dataGrid/paginationPageSize";
import { DEFAULT_QUERY_RESULT_MAX_ROWS, normalizeQueryResultMaxRows } from "@/lib/dataGrid/queryResultRowLimit";
import { normalizeConnectTimeoutSecs, normalizeQueryTimeoutSecs } from "@/lib/connection/timeoutLimits";
@@ -413,6 +414,7 @@ const DATA_GRID_RENDER_MODES = ["dom", "canvas"] as const;
export type DataGridRenderMode = (typeof DATA_GRID_RENDER_MODES)[number];
const DATA_GRID_SEARCH_MODES = ["filter", "highlight"] as const;
export type DataGridSearchMode = (typeof DATA_GRID_SEARCH_MODES)[number];
export type DataGridFilterEditorView = "quick" | "conditions" | "text";
const RESULT_RUN_DISPLAY_MODES = ["tabs", "list"] as const;
export type ResultRunDisplayMode = (typeof RESULT_RUN_DISPLAY_MODES)[number];
export const TABLE_FONT_SIZE_MIN = 8;
@@ -553,6 +555,8 @@ export interface EditorSettings {
compactColumnHeaderActions: boolean;
columnWidthDensity: ColumnWidthDensity;
dataGridQuickEntry: boolean;
dataGridFilterEditorView: DataGridFilterEditorView;
dataGridTextFilterPanelHeight: number;
dataGridRenderMode: DataGridRenderMode;
dataGridSearchMode: DataGridSearchMode;
dataGridCopyExtractor: DataGridCopyPreference;
@@ -754,6 +758,8 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
compactColumnHeaderActions: true,
columnWidthDensity: "standard",
dataGridQuickEntry: false,
dataGridFilterEditorView: "quick",
dataGridTextFilterPanelHeight: DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_DEFAULT,
dataGridRenderMode: "canvas",
dataGridSearchMode: "filter",
dataGridCopyExtractor: "smart",
@@ -873,6 +879,10 @@ function normalizeDataGridSearchMode(value: unknown): DataGridSearchMode {
return DATA_GRID_SEARCH_MODES.includes(value as DataGridSearchMode) ? (value as DataGridSearchMode) : DEFAULT_EDITOR_SETTINGS.dataGridSearchMode;
}
function normalizeDataGridFilterEditorView(value: unknown): DataGridFilterEditorView {
return value === "conditions" || value === "text" ? value : DEFAULT_EDITOR_SETTINGS.dataGridFilterEditorView;
}
function normalizeResultRunDisplayMode(value: unknown): ResultRunDisplayMode {
return RESULT_RUN_DISPLAY_MODES.includes(value as ResultRunDisplayMode) ? (value as ResultRunDisplayMode) : DEFAULT_EDITOR_SETTINGS.resultRunDisplayMode;
}
@@ -1110,6 +1120,8 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
compactColumnHeaderActions: settings.compactColumnHeaderActions ?? DEFAULT_EDITOR_SETTINGS.compactColumnHeaderActions,
columnWidthDensity: normalizeColumnWidthDensity(settings.columnWidthDensity),
dataGridQuickEntry: settings.dataGridQuickEntry ?? DEFAULT_EDITOR_SETTINGS.dataGridQuickEntry,
dataGridFilterEditorView: normalizeDataGridFilterEditorView(settings.dataGridFilterEditorView),
dataGridTextFilterPanelHeight: normalizeDataGridTextFilterPanelHeight(settings.dataGridTextFilterPanelHeight),
dataGridRenderMode: normalizeDataGridRenderMode(settings.dataGridRenderMode),
dataGridSearchMode: normalizeDataGridSearchMode(settings.dataGridSearchMode),
dataGridCopyExtractor: normalizeDataGridCopyPreference(settings.dataGridCopyExtractor),
@@ -1694,6 +1706,8 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.compactColumnHeaderActions !== undefined) editorSettings.value.compactColumnHeaderActions = partial.compactColumnHeaderActions;
if (partial.columnWidthDensity !== undefined) editorSettings.value.columnWidthDensity = normalizeColumnWidthDensity(partial.columnWidthDensity);
if (partial.dataGridQuickEntry !== undefined) editorSettings.value.dataGridQuickEntry = partial.dataGridQuickEntry;
if (partial.dataGridFilterEditorView !== undefined) editorSettings.value.dataGridFilterEditorView = normalizeDataGridFilterEditorView(partial.dataGridFilterEditorView);
if (partial.dataGridTextFilterPanelHeight !== undefined) editorSettings.value.dataGridTextFilterPanelHeight = normalizeDataGridTextFilterPanelHeight(partial.dataGridTextFilterPanelHeight);
if (partial.dataGridRenderMode !== undefined) editorSettings.value.dataGridRenderMode = normalizeDataGridRenderMode(partial.dataGridRenderMode);
if (partial.dataGridSearchMode !== undefined) editorSettings.value.dataGridSearchMode = normalizeDataGridSearchMode(partial.dataGridSearchMode);
if (partial.dataGridCopyExtractor !== undefined) editorSettings.value.dataGridCopyExtractor = normalizeDataGridCopyPreference(partial.dataGridCopyExtractor);