mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(grid): add persistent SQL filter views
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "クエリ結果のデフォルト行数",
|
||||
|
||||
@@ -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: "쿼리 결과 기본 페이지당 행 수",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "查询结果默认每页行数",
|
||||
|
||||
@@ -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: "查詢結果預設每頁列數",
|
||||
|
||||
+31
-16
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user