feat(grid): add persistent SQL filter views
This commit is contained in:
27 files changed
+1428
-107
No files matched your search
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user