mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
feat(grid): add suggested values to filtering criteria
This commit is contained in:
@@ -253,6 +253,8 @@ import {
|
||||
buildColumnValueFilterCondition,
|
||||
buildColumnValuesFilterCondition,
|
||||
combineWhereInputs,
|
||||
formatFilterRawValue,
|
||||
formatFilterRawValues,
|
||||
filterModeHasCompleteValue,
|
||||
filterModeIsSupportedForDatabase,
|
||||
filterModeNeedsValue,
|
||||
@@ -329,6 +331,8 @@ import { useDataGridResultLifecycle } from "@/composables/useDataGridResultLifec
|
||||
import { useDataGridAutoRefresh } from "@/composables/useDataGridAutoRefresh";
|
||||
import { useDataGridAsyncSurface } from "@/composables/useDataGridAsyncSurface";
|
||||
import { createDataGridFilterConditionCache, useDataGridFilterBuilder, type DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
import { DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT, useDataGridDistinctValueLoader } from "@/composables/useDataGridDistinctValueLoader";
|
||||
import { dataGridDistinctValueKey, dataGridNullSuggestionFilterMode, toggleAllDataGridDistinctValueOptions, type DataGridDistinctValueSuggestionState, type DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
import { cloneDataGridStructuredFilterRules, loadDataGridStructuredFilterState, saveDataGridStructuredFilterState, type DataGridCachedServerColumnFilter, type DataGridStructuredFilterCacheState } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||||
import { createDataGridSearchScopeKey } from "@/lib/dataGrid/dataGridSearchStatePersistence";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
@@ -1121,8 +1125,6 @@ const conditionHistoryScope = computed(() => ({
|
||||
tableName: props.tableMeta?.tableName,
|
||||
}));
|
||||
type LocalFilterMode = "local" | "server";
|
||||
type LocalFilterOption = DataGridLocalFilterOption;
|
||||
|
||||
type LocalColumnFilterDraft = {
|
||||
columnIndex: number;
|
||||
values: Set<string>;
|
||||
@@ -1168,11 +1170,6 @@ let localFilterResizeStartWidth = LOCAL_FILTER_POPOVER_DEFAULT_WIDTH;
|
||||
let localFilterResizeStartOffsetX = 0;
|
||||
let localFilterResizeStartLeft = 0;
|
||||
let localFilterResizeStartRight = 0;
|
||||
const serverFilterLoading = ref(false);
|
||||
const serverFilterError = ref("");
|
||||
const serverFilterOptions = ref<LocalFilterOption[]>([]);
|
||||
const serverFilterLimited = ref(false);
|
||||
const serverFilterValueByKey = ref<Map<string, CellValue>>(new Map());
|
||||
const serverColumnFilters = ref<Record<number, DataGridCachedServerColumnFilter>>({});
|
||||
let getGridNewRows: () => readonly (readonly CellValue[])[] = () => [];
|
||||
let getGridRowData: (row: CellValue[], sourceIndex: number) => readonly CellValue[] = (row) => row;
|
||||
@@ -1276,6 +1273,34 @@ const filterBuilderOpen = filterBuilder.open;
|
||||
const filterBuilderColumnSearch = filterBuilder.columnSearch;
|
||||
const filteredFilterBuilderColumnOptions = filterBuilder.filteredColumns;
|
||||
const appliedStructuredWhereInput = filterBuilder.appliedWhereInput;
|
||||
const filterValueSuggestionRuleId = ref<string>();
|
||||
const filterValueSuggestionTarget = ref<DataGridDistinctValueSuggestionTarget>();
|
||||
const filterValueSuggestionSearch = ref("");
|
||||
const filterValueSuggestionDraftValues = ref(new Map<string, CellValue>());
|
||||
const filterValueSuggestionLoader = useDataGridDistinctValueLoader({
|
||||
scopeIdentity: structuredFilterScopeKey,
|
||||
getConnectionId: () => props.connectionId,
|
||||
getExecutionDatabase: () => props.executionDatabase ?? props.database ?? "",
|
||||
getSchema: () => props.schema,
|
||||
getDatabaseType: () => resolvedDatabaseType.value,
|
||||
getConnectionConfig: () => (props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined),
|
||||
getIdentifierQuote: () => connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
getGlobalQueryTimeoutSecs: () => settingsStore.editorSettings.globalQueryTimeoutSecs,
|
||||
waitForTableMeta,
|
||||
formatValue: (value, columnIndex) => formatCellCached(value, columnIndex),
|
||||
keyForValue: dataGridDistinctValueKey,
|
||||
});
|
||||
const filterValueSuggestionState = computed<DataGridDistinctValueSuggestionState>(() => ({
|
||||
ruleId: filterValueSuggestionRuleId.value,
|
||||
target: filterValueSuggestionTarget.value,
|
||||
search: filterValueSuggestionSearch.value,
|
||||
options: filterValueSuggestionLoader.options.value,
|
||||
loading: filterValueSuggestionLoader.loading.value,
|
||||
error: filterValueSuggestionLoader.error.value,
|
||||
limited: filterValueSuggestionLoader.limited.value,
|
||||
limit: DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT,
|
||||
selectedKeys: new Set(filterValueSuggestionDraftValues.value.keys()),
|
||||
}));
|
||||
// Structured filter rules are restored asynchronously. A tab-switch snapshot's
|
||||
// probe includes the applied condition, so restoring before this hydration
|
||||
// settles would reject an otherwise valid snapshot and never retry it.
|
||||
@@ -1308,11 +1333,6 @@ const localColumnFilterRuntime = useDataGridColumnFilters({
|
||||
localFilterOpenColumn,
|
||||
localFilterSearch,
|
||||
localFilterDraft,
|
||||
serverFilterLoading,
|
||||
serverFilterError,
|
||||
serverFilterOptions,
|
||||
serverFilterLimited,
|
||||
serverFilterValueByKey,
|
||||
serverColumnFilters,
|
||||
},
|
||||
getResult: () => props.result,
|
||||
@@ -1320,6 +1340,7 @@ const localColumnFilterRuntime = useDataGridColumnFilters({
|
||||
getConnectionId: () => props.connectionId,
|
||||
getSchema: () => props.schema,
|
||||
getExecutionDatabase: () => props.executionDatabase ?? props.database ?? "",
|
||||
scopeIdentity: structuredFilterScopeKey,
|
||||
resolvedDatabaseType,
|
||||
canUseWhereSearch,
|
||||
canUseServerColumnFilter,
|
||||
@@ -1328,6 +1349,7 @@ const localColumnFilterRuntime = useDataGridColumnFilters({
|
||||
whereFilterInput,
|
||||
getConnectionConfig: () => (props.connectionId ? connectionStore.getConfig(props.connectionId) : undefined),
|
||||
getIdentifierQuote: () => connectionStore.connectionIdentifierQuote?.(props.connectionId),
|
||||
getGlobalQueryTimeoutSecs: () => settingsStore.editorSettings.globalQueryTimeoutSecs,
|
||||
getNewRows: () => getGridNewRows(),
|
||||
getRowData: (row, sourceIndex) => getGridRowData(row, sourceIndex),
|
||||
formatValue: formatCellCached,
|
||||
@@ -1359,6 +1381,10 @@ const {
|
||||
toggleLocalFilterSort,
|
||||
localFilterTypedValue,
|
||||
canApplyTypedLocalFilterValue,
|
||||
serverFilterLoading,
|
||||
serverFilterError,
|
||||
serverFilterLimited,
|
||||
resetDistinctValueCache,
|
||||
openLocalFilter,
|
||||
closeLocalFilter,
|
||||
toggleLocalFilterValue,
|
||||
@@ -1532,6 +1558,89 @@ async function buildStructuredWhereFromRules(rules: StructuredFilterRule[]): Pro
|
||||
);
|
||||
}
|
||||
|
||||
function closeFilterValueSuggestions() {
|
||||
filterValueSuggestionRuleId.value = undefined;
|
||||
filterValueSuggestionTarget.value = undefined;
|
||||
filterValueSuggestionSearch.value = "";
|
||||
filterValueSuggestionDraftValues.value = new Map();
|
||||
filterValueSuggestionLoader.reset();
|
||||
}
|
||||
|
||||
function filterValueSuggestionRule(): StructuredFilterRule | undefined {
|
||||
return structuredFilterRules.value.find((rule) => rule.id === filterValueSuggestionRuleId.value);
|
||||
}
|
||||
|
||||
function filterValueSuggestionColumnIndex(columnName: string): number {
|
||||
const exact = props.result.columns.indexOf(columnName);
|
||||
if (exact >= 0) return exact;
|
||||
const normalized = columnName.toLowerCase();
|
||||
return props.result.columns.findIndex((column) => column.toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
function filterValueSuggestionRequest(searchValue = filterValueSuggestionSearch.value) {
|
||||
const rule = filterValueSuggestionRule();
|
||||
if (!rule) return undefined;
|
||||
const columnIndex = filterValueSuggestionColumnIndex(rule.columnName);
|
||||
if (columnIndex < 0) return undefined;
|
||||
return {
|
||||
columnIndex,
|
||||
columnName: rule.columnName,
|
||||
searchValue,
|
||||
limit: DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT,
|
||||
includeCounts: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function openFilterValueSuggestions(ruleId: string, target: DataGridDistinctValueSuggestionTarget) {
|
||||
const rule = structuredFilterRules.value.find((item) => item.id === ruleId);
|
||||
if (!rule || rule.disabled || !rule.columnName || !filterModeNeedsValue(rule.mode)) return;
|
||||
const columnInfo = filterBuilderColumns.value.find((column) => column.name === rule.columnName)?.columnInfo;
|
||||
const currentValues = filterModeUsesList(rule.mode) ? parseFilterValues(rule.rawValue, columnInfo, resolvedDatabaseType.value) : [];
|
||||
filterValueSuggestionRuleId.value = ruleId;
|
||||
filterValueSuggestionTarget.value = target;
|
||||
filterValueSuggestionSearch.value = "";
|
||||
filterValueSuggestionDraftValues.value = new Map(currentValues.filter((value) => value === null || typeof value !== "object").map((value): [string, CellValue] => [dataGridDistinctValueKey(value, columnInfo), value]));
|
||||
filterValueSuggestionLoader.reset();
|
||||
const request = filterValueSuggestionRequest();
|
||||
if (request) await filterValueSuggestionLoader.load(request);
|
||||
}
|
||||
|
||||
function updateFilterValueSuggestionSearch(value: string) {
|
||||
filterValueSuggestionSearch.value = value;
|
||||
const request = filterValueSuggestionRequest(value);
|
||||
if (request) filterValueSuggestionLoader.schedule(request);
|
||||
}
|
||||
|
||||
function selectFilterValueSuggestion(option: DataGridLocalFilterOption) {
|
||||
const rule = filterValueSuggestionRule();
|
||||
const target = filterValueSuggestionTarget.value;
|
||||
if (!rule || !target) return;
|
||||
if (option.value === null) {
|
||||
filterBuilder.updateRule(rule.id, { mode: dataGridNullSuggestionFilterMode(rule.mode), rawValue: "", rawEndValue: "" });
|
||||
} else {
|
||||
filterBuilder.updateRule(rule.id, target === "end" ? { rawEndValue: formatFilterRawValue(option.value) } : { rawValue: formatFilterRawValue(option.value) });
|
||||
}
|
||||
closeFilterValueSuggestions();
|
||||
}
|
||||
|
||||
function toggleFilterValueSuggestion(option: DataGridLocalFilterOption) {
|
||||
const next = new Map(filterValueSuggestionDraftValues.value);
|
||||
if (next.has(option.key)) next.delete(option.key);
|
||||
else next.set(option.key, option.value);
|
||||
filterValueSuggestionDraftValues.value = next;
|
||||
}
|
||||
|
||||
function toggleAllFilterValueSuggestions() {
|
||||
filterValueSuggestionDraftValues.value = toggleAllDataGridDistinctValueOptions(filterValueSuggestionDraftValues.value, filterValueSuggestionLoader.options.value);
|
||||
}
|
||||
|
||||
function applyFilterValueSuggestions() {
|
||||
const rule = filterValueSuggestionRule();
|
||||
if (!rule || !filterModeUsesList(rule.mode)) return;
|
||||
filterBuilder.updateRule(rule.id, { rawValue: formatFilterRawValues([...filterValueSuggestionDraftValues.value.values()]) });
|
||||
closeFilterValueSuggestions();
|
||||
}
|
||||
|
||||
function persistStructuredFilterState() {
|
||||
saveDataGridStructuredFilterState(structuredFilterCacheKey.value, {
|
||||
scopeKey: structuredFilterScopeKey.value,
|
||||
@@ -1591,10 +1700,12 @@ function addStructuredFilterRule() {
|
||||
}
|
||||
|
||||
function removeStructuredFilterRule(ruleId: string) {
|
||||
if (filterValueSuggestionRuleId.value === ruleId) closeFilterValueSuggestions();
|
||||
filterBuilder.removeRule(ruleId);
|
||||
}
|
||||
|
||||
function updateStructuredFilterRule(ruleId: string, patch: Partial<StructuredFilterRule>) {
|
||||
if (filterValueSuggestionRuleId.value === ruleId) closeFilterValueSuggestions();
|
||||
filterBuilder.updateRule(ruleId, patch);
|
||||
}
|
||||
|
||||
@@ -1607,6 +1718,7 @@ function updateTextFilterPanelHeight(height: number) {
|
||||
}
|
||||
|
||||
function resetStructuredFilters() {
|
||||
closeFilterValueSuggestions();
|
||||
filterBuilder.reset();
|
||||
}
|
||||
|
||||
@@ -1714,6 +1826,7 @@ function copyFilterSqlPreview() {
|
||||
watch([structuredFilterCacheKey, structuredFilterScopeKey], loadStructuredFilterStateForScope, { immediate: true });
|
||||
|
||||
watch(filterEditorView, (view) => {
|
||||
closeFilterValueSuggestions();
|
||||
filterBuilderOpen.value = (view === "conditions" || view === "text") && settingsStore.editorSettings.dataGridKeepFilterEditorExpanded;
|
||||
if (view === "conditions" || view === "text") ensureStructuredFilterRule();
|
||||
});
|
||||
@@ -10207,6 +10320,8 @@ watch(
|
||||
}
|
||||
return;
|
||||
}
|
||||
resetDistinctValueCache();
|
||||
filterValueSuggestionLoader.reset({ clearCache: true });
|
||||
// A non-append result replaces the whole data set, so a running "load all" is over.
|
||||
loadAllRowsActive.value = false;
|
||||
// The replacement also invalidates the all-loaded marker: a filter change or
|
||||
@@ -12281,6 +12396,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
:filtered-columns="filteredFilterBuilderColumnOptions"
|
||||
:mode-options="filterModeOptions"
|
||||
:column-search="filterBuilderColumnSearch"
|
||||
:value-suggestions="canUseServerColumnFilter ? filterValueSuggestionState : undefined"
|
||||
:apply-where="applyWhereFilter"
|
||||
:apply-order-by="applyOrderBySearch"
|
||||
:clear-order-by="clearOrderByInput"
|
||||
@@ -12296,6 +12412,13 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
@move-rule="moveStructuredFilterRule"
|
||||
@update-rule="updateStructuredFilterRule"
|
||||
@clear-local-filter="clearLocalFilter"
|
||||
@open-value-suggestions="openFilterValueSuggestions"
|
||||
@close-value-suggestions="closeFilterValueSuggestions"
|
||||
@update-value-suggestion-search="updateFilterValueSuggestionSearch"
|
||||
@select-value-suggestion="selectFilterValueSuggestion"
|
||||
@toggle-value-suggestion="toggleFilterValueSuggestion"
|
||||
@toggle-all-value-suggestions="toggleAllFilterValueSuggestions"
|
||||
@apply-value-suggestions="applyFilterValueSuggestions"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -12446,6 +12569,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
@add-rule="addStructuredFilterRule"
|
||||
@apply="applyStructuredFilters"
|
||||
:apply-only-busy="applyingOnlyStructuredFilter || isApplyingWhere"
|
||||
:value-suggestions="canUseServerColumnFilter ? filterValueSuggestionState : undefined"
|
||||
@apply-only="applyOnlyStructuredFilter"
|
||||
@reset="resetStructuredFilters"
|
||||
@clear="clearAllFilters"
|
||||
@@ -12453,6 +12577,13 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
@remove-rule="removeStructuredFilterRule"
|
||||
@move-rule="moveStructuredFilterRule"
|
||||
@update-rule="updateStructuredFilterRule"
|
||||
@open-value-suggestions="openFilterValueSuggestions"
|
||||
@close-value-suggestions="closeFilterValueSuggestions"
|
||||
@update-value-suggestion-search="updateFilterValueSuggestionSearch"
|
||||
@select-value-suggestion="selectFilterValueSuggestion"
|
||||
@toggle-value-suggestion="toggleFilterValueSuggestion"
|
||||
@toggle-all-value-suggestions="toggleAllFilterValueSuggestions"
|
||||
@apply-value-suggestions="applyFilterValueSuggestions"
|
||||
/>
|
||||
<DataGridTextFilterWorkbench
|
||||
v-if="canUseWhereSearch && filterEditorView === 'text' && filterBuilderOpen"
|
||||
@@ -12464,6 +12595,7 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
:mode-options="filterModeOptions"
|
||||
:column-search="filterBuilderColumnSearch"
|
||||
:disabled="!canUseWhereSearch"
|
||||
:value-suggestions="canUseServerColumnFilter ? filterValueSuggestionState : undefined"
|
||||
@update:height="updateTextFilterPanelHeight"
|
||||
@update:column-search="filterBuilderColumnSearch = $event"
|
||||
@ensure-rule="ensureStructuredFilterRule"
|
||||
@@ -12477,6 +12609,13 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
|
||||
@remove-rule="removeStructuredFilterRule"
|
||||
@move-rule="moveStructuredFilterRule"
|
||||
@update-rule="updateStructuredFilterRule"
|
||||
@open-value-suggestions="openFilterValueSuggestions"
|
||||
@close-value-suggestions="closeFilterValueSuggestions"
|
||||
@update-value-suggestion-search="updateFilterValueSuggestionSearch"
|
||||
@select-value-suggestion="selectFilterValueSuggestion"
|
||||
@toggle-value-suggestion="toggleFilterValueSuggestion"
|
||||
@toggle-all-value-suggestions="toggleAllFilterValueSuggestions"
|
||||
@apply-value-suggestions="applyFilterValueSuggestions"
|
||||
/>
|
||||
<!-- 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">
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Check, Database, Loader2, Search } from "@lucide/vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
search: string;
|
||||
options: DataGridLocalFilterOption[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
limited: boolean;
|
||||
limit: number;
|
||||
multiple?: boolean;
|
||||
selectedKeys?: Set<string>;
|
||||
disabled?: boolean;
|
||||
compact?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:open": [open: boolean];
|
||||
"update:search": [search: string];
|
||||
select: [option: DataGridLocalFilterOption];
|
||||
toggle: [option: DataGridLocalFilterOption];
|
||||
toggleAll: [];
|
||||
apply: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const allOptionsSelected = computed(() => props.options.length > 0 && props.options.every((option) => props.selectedKeys?.has(option.key)));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<PopoverTrigger as-child>
|
||||
<Button variant="outline" size="icon" class="shrink-0" :class="props.compact ? 'h-6 w-6 rounded-none border-0 bg-transparent shadow-none' : 'h-7 w-7'" :disabled="props.disabled" :title="t('grid.databaseValueFilter')" :aria-label="t('grid.databaseValueFilter')">
|
||||
<Database class="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" side="bottom" class="w-80 gap-0 overflow-hidden p-0" @keydown.stop>
|
||||
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
|
||||
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
:value="props.search"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="h-7 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
|
||||
:placeholder="t('grid.searchValues')"
|
||||
@input="emit('update:search', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid border-b bg-muted/40 px-2 py-1 text-xs font-medium text-muted-foreground" :class="props.multiple ? 'grid-cols-[1.75rem_minmax(0,1fr)_3.5rem]' : 'grid-cols-[minmax(0,1fr)_3.5rem]'">
|
||||
<button
|
||||
v-if="props.multiple"
|
||||
type="button"
|
||||
data-distinct-value-select-all
|
||||
role="checkbox"
|
||||
class="flex h-4 w-4 items-center justify-center rounded border"
|
||||
:class="allOptionsSelected ? 'border-blue-600 bg-blue-600 text-white' : 'border-border bg-background text-foreground/70'"
|
||||
:disabled="!props.options.length"
|
||||
:aria-checked="allOptionsSelected"
|
||||
@click="emit('toggleAll')"
|
||||
>
|
||||
<Check v-if="allOptionsSelected" class="h-3 w-3 stroke-[3]" />
|
||||
</button>
|
||||
<span>{{ t("grid.value") }}</span>
|
||||
<span class="text-right">{{ t("grid.count") }}</span>
|
||||
</div>
|
||||
<div v-if="props.loading || props.error || props.limited" class="flex items-center gap-1.5 border-b px-2 py-1 text-[11px] text-muted-foreground">
|
||||
<Loader2 v-if="props.loading" class="h-3 w-3 animate-spin" />
|
||||
<span class="min-w-0 truncate">
|
||||
<template v-if="props.loading">{{ t("grid.loadingValues") }}</template>
|
||||
<template v-else-if="props.error">{{ props.error }}</template>
|
||||
<template v-else>{{ t("grid.serverValuesLimited", { count: props.limit }) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="max-h-72 overflow-auto py-0.5">
|
||||
<button
|
||||
v-for="option in props.options"
|
||||
:key="option.key"
|
||||
type="button"
|
||||
class="grid w-full items-center px-2 py-1 text-left text-xs hover:bg-accent"
|
||||
:class="props.multiple ? 'grid-cols-[1.75rem_minmax(0,1fr)_3.5rem]' : 'grid-cols-[minmax(0,1fr)_3.5rem]'"
|
||||
@click="props.multiple ? emit('toggle', option) : emit('select', option)"
|
||||
>
|
||||
<span v-if="props.multiple" class="flex h-4 w-4 items-center justify-center rounded border" :class="props.selectedKeys?.has(option.key) ? 'border-blue-600 bg-blue-600 text-white' : 'border-border bg-background text-foreground/70'">
|
||||
<Check v-if="props.selectedKeys?.has(option.key)" class="h-3 w-3 stroke-[3]" />
|
||||
</span>
|
||||
<span class="truncate font-mono">{{ option.label }}</span>
|
||||
<span class="text-right text-xs tabular-nums text-muted-foreground">{{ option.count ?? "" }}</span>
|
||||
</button>
|
||||
<div v-if="!props.options.length && !props.loading && !props.error" class="px-2 py-6 text-center text-xs text-muted-foreground">
|
||||
{{ t("grid.noSearchResults") }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="props.multiple" class="flex justify-end gap-2 border-t bg-muted/40 px-2 py-1.5">
|
||||
<Button variant="outline" size="sm" class="h-7 px-2 text-xs" @click="emit('update:open', false)">
|
||||
{{ t("dangerDialog.cancel") }}
|
||||
</Button>
|
||||
<Button size="sm" class="h-7 px-2 text-xs" @click="emit('apply')">{{ t("grid.applyFilter") }}</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -6,11 +6,14 @@ import { Button } from "@/components/ui/button";
|
||||
import LightTooltip from "@/components/ui/LightTooltip.vue";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import DataGridDistinctValuePopover from "@/components/grid/DataGridDistinctValuePopover.vue";
|
||||
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";
|
||||
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
|
||||
const { t } = useI18n();
|
||||
const VALUE_SHORTCUT_HINT_STORAGE_KEY = "dbx-filter-builder-value-shortcut-hint-days";
|
||||
@@ -38,6 +41,7 @@ const props = withDefaults(
|
||||
showHeader?: boolean;
|
||||
showFooter?: boolean;
|
||||
layout?: "popover" | "panel" | "text";
|
||||
valueSuggestions?: DataGridDistinctValueSuggestionState;
|
||||
}>(),
|
||||
{ showHeader: true, showFooter: true, layout: "popover" },
|
||||
);
|
||||
@@ -51,6 +55,13 @@ const emit = defineEmits<{
|
||||
move: [id: string, targetIndex: number];
|
||||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
"update:columnSearch": [value: string];
|
||||
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
|
||||
closeValueSuggestions: [];
|
||||
updateValueSuggestionSearch: [value: string];
|
||||
selectValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleAllValueSuggestions: [];
|
||||
applyValueSuggestions: [];
|
||||
}>();
|
||||
const columnSearchInputs = new Map<string, HTMLInputElement>();
|
||||
const filterRuleElements = new Map<string, HTMLElement>();
|
||||
@@ -95,6 +106,19 @@ function usesExpandedLayout(mode: DataGridContextFilterMode) {
|
||||
return filterModeUsesList(mode) || filterModeUsesRange(mode);
|
||||
}
|
||||
|
||||
function canSuggestValues(rule: DataGridStructuredFilterRule) {
|
||||
return !!props.valueSuggestions && !!rule.columnName && !rule.disabled && filterModeNeedsValue(rule.mode);
|
||||
}
|
||||
|
||||
function valueSuggestionsOpen(rule: DataGridStructuredFilterRule, target: DataGridDistinctValueSuggestionTarget) {
|
||||
return props.valueSuggestions?.ruleId === rule.id && props.valueSuggestions.target === target;
|
||||
}
|
||||
|
||||
function updateValueSuggestionsOpen(rule: DataGridStructuredFilterRule, target: DataGridDistinctValueSuggestionTarget, open: boolean) {
|
||||
if (open) emit("openValueSuggestions", rule.id, target);
|
||||
else if (valueSuggestionsOpen(rule, target)) emit("closeValueSuggestions");
|
||||
}
|
||||
|
||||
function currentLocalDateKey() {
|
||||
const now = new Date();
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
@@ -621,6 +645,20 @@ function blurValueRule(id: string) {
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown="handleValueEditorKeydown($event, `value-start:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
compact
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
<span class="shrink-0 text-[10px] text-muted-foreground">—</span>
|
||||
<Input
|
||||
:model-value="rule.rawEndValue"
|
||||
@@ -630,34 +668,82 @@ function blurValueRule(id: string) {
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })"
|
||||
@keydown="handleValueEditorKeydown($event, `value-end:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
compact
|
||||
:open="valueSuggestionsOpen(rule, 'end')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'end', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="filterModeUsesList(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.filterBuilderValues')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown="handleValueEditorKeydown($event, `value-list:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
compact
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
:multiple="true"
|
||||
:selected-keys="props.valueSuggestions.selectedKeys"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@toggle="emit('toggleValueSuggestion', $event)"
|
||||
@toggle-all="emit('toggleAllValueSuggestions')"
|
||||
@apply="emit('applyValueSuggestions')"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="filterModeNeedsValue(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.filterBuilderTextValue')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
compact
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
</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"
|
||||
class="h-7 text-xs"
|
||||
class="h-7 min-w-0 flex-1 text-xs"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderRangeStart')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@@ -667,9 +753,22 @@ function blurValueRule(id: string) {
|
||||
@compositionstart="startImeComposition(`value-start:${rule.id}`)"
|
||||
@keydown="handleValueEditorKeydown($event, `value-start:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
<Input
|
||||
:model-value="rule.rawEndValue"
|
||||
class="h-7 text-xs"
|
||||
class="h-7 min-w-0 flex-1 text-xs"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderRangeEnd')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawEndValue: String(value ?? '') })"
|
||||
@@ -679,33 +778,78 @@ function blurValueRule(id: string) {
|
||||
@compositionstart="startImeComposition(`value-end:${rule.id}`)"
|
||||
@keydown="handleValueEditorKeydown($event, `value-end:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
:open="valueSuggestionsOpen(rule, 'end')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'end', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="filterModeUsesList(rule.mode)" class="col-start-2 col-span-3 flex items-start gap-1.5">
|
||||
<textarea
|
||||
data-filter-value-editor
|
||||
:value="rule.rawValue"
|
||||
rows="2"
|
||||
class="min-h-12 min-w-0 flex-1 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 })"
|
||||
@keydown.ctrl.enter.prevent="emit('apply')"
|
||||
@keydown.meta.enter.prevent="emit('apply')"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
:multiple="true"
|
||||
:selected-keys="props.valueSuggestions.selectedKeys"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@toggle="emit('toggleValueSuggestion', $event)"
|
||||
@toggle-all="emit('toggleAllValueSuggestions')"
|
||||
@apply="emit('applyValueSuggestions')"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="filterModeNeedsValue(rule.mode)" class="col-start-4 flex min-w-0 gap-1.5">
|
||||
<Input
|
||||
data-filter-value-editor
|
||||
:model-value="rule.rawValue"
|
||||
class="h-7 min-w-0 flex-1 text-xs"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderValue')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@focus="focusValueRule(rule.id, index, rule.mode)"
|
||||
@blur="blurValueRule(rule.id)"
|
||||
@compositionend="endImeComposition(`value:${rule.id}`)"
|
||||
@compositionstart="startImeComposition(`value:${rule.id}`)"
|
||||
@keydown="handleValueEditorKeydown($event, `value:${rule.id}`)"
|
||||
/>
|
||||
<DataGridDistinctValuePopover
|
||||
v-if="canSuggestValues(rule) && props.valueSuggestions"
|
||||
:open="valueSuggestionsOpen(rule, 'value')"
|
||||
:search="props.valueSuggestions.search"
|
||||
:options="props.valueSuggestions.options"
|
||||
:loading="props.valueSuggestions.loading"
|
||||
:error="props.valueSuggestions.error"
|
||||
:limited="props.valueSuggestions.limited"
|
||||
:limit="props.valueSuggestions.limit"
|
||||
@update:open="updateValueSuggestionsOpen(rule, 'value', $event)"
|
||||
@update:search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select="emit('selectValueSuggestion', $event)"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
v-else-if="filterModeUsesList(rule.mode)"
|
||||
data-filter-value-editor
|
||||
:value="rule.rawValue"
|
||||
rows="2"
|
||||
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 })"
|
||||
@keydown.ctrl.enter.prevent="emit('apply')"
|
||||
@keydown.meta.enter.prevent="emit('apply')"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="filterModeNeedsValue(rule.mode)"
|
||||
data-filter-value-editor
|
||||
:model-value="rule.rawValue"
|
||||
class="h-7 text-xs"
|
||||
:disabled="rule.disabled"
|
||||
:placeholder="t('grid.filterBuilderValue')"
|
||||
@update:model-value="(value) => emit('updateRule', rule.id, { rawValue: String(value ?? '') })"
|
||||
@focus="focusValueRule(rule.id, index, rule.mode)"
|
||||
@blur="blurValueRule(rule.id)"
|
||||
@compositionend="endImeComposition(`value:${rule.id}`)"
|
||||
@compositionstart="startImeComposition(`value:${rule.id}`)"
|
||||
@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="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") }}
|
||||
|
||||
@@ -6,6 +6,8 @@ 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";
|
||||
import type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
|
||||
const props = defineProps<{
|
||||
sqlPreview: string;
|
||||
@@ -16,6 +18,7 @@ const props = defineProps<{
|
||||
columnSearch: string;
|
||||
applyOnlyBusy?: boolean;
|
||||
disabled?: boolean;
|
||||
valueSuggestions?: DataGridDistinctValueSuggestionState;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -30,6 +33,13 @@ const emit = defineEmits<{
|
||||
removeRule: [id: string];
|
||||
moveRule: [id: string, targetIndex: number];
|
||||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
|
||||
closeValueSuggestions: [];
|
||||
updateValueSuggestionSearch: [value: string];
|
||||
selectValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleAllValueSuggestions: [];
|
||||
applyValueSuggestions: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -64,6 +74,7 @@ watch(
|
||||
:mode-options="modeOptions"
|
||||
:column-search="columnSearch"
|
||||
:disabled="disabled"
|
||||
:value-suggestions="valueSuggestions"
|
||||
layout="panel"
|
||||
:show-header="false"
|
||||
:show-footer="false"
|
||||
@@ -76,6 +87,13 @@ watch(
|
||||
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
|
||||
@update-rule="(id, patch) => emit('updateRule', id, patch)"
|
||||
@update:column-search="emit('update:columnSearch', $event)"
|
||||
@open-value-suggestions="(id, target) => emit('openValueSuggestions', id, target)"
|
||||
@close-value-suggestions="emit('closeValueSuggestions')"
|
||||
@update-value-suggestion-search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select-value-suggestion="emit('selectValueSuggestion', $event)"
|
||||
@toggle-value-suggestion="emit('toggleValueSuggestion', $event)"
|
||||
@toggle-all-value-suggestions="emit('toggleAllValueSuggestions')"
|
||||
@apply-value-suggestions="emit('applyValueSuggestions')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ 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 { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import type { DataGridFilterEditorView } from "@/stores/settingsStore";
|
||||
import { clampSearchSplitWidth } from "@/lib/dataGrid/dataGridSearchSplit";
|
||||
|
||||
@@ -42,6 +44,7 @@ const props = defineProps<{
|
||||
modeOptions: Array<{ value: DataGridContextFilterMode; labelKey: string }>;
|
||||
columnSearch: string;
|
||||
applyOnlyBusy?: boolean;
|
||||
valueSuggestions?: DataGridDistinctValueSuggestionState;
|
||||
applyWhere: (value?: string) => void | boolean | Promise<void | boolean>;
|
||||
applyOrderBy: (value?: string) => void | boolean | Promise<void | boolean>;
|
||||
clearOrderBy: () => void | Promise<void>;
|
||||
@@ -62,6 +65,13 @@ const emit = defineEmits<{
|
||||
moveRule: [id: string, targetIndex: number];
|
||||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
clearLocalFilter: [columnIndex?: number];
|
||||
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
|
||||
closeValueSuggestions: [];
|
||||
updateValueSuggestionSearch: [value: string];
|
||||
selectValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleAllValueSuggestions: [];
|
||||
applyValueSuggestions: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -151,6 +161,11 @@ async function handleFilterButtonClick() {
|
||||
await openPendingFirstEmptyRuleColumnSearch();
|
||||
}
|
||||
|
||||
function updateQuickFilterBuilderOpen(open: boolean) {
|
||||
emit("update:filterBuilderOpen", open);
|
||||
if (!open) emit("closeValueSuggestions");
|
||||
}
|
||||
|
||||
watch([() => props.filterBuilderOpen, () => props.rules.map((rule) => `${rule.id}:${rule.columnName}:${rule.disabled ? "1" : "0"}`).join("\u0000"), filterBuilderRef], () => void openPendingFirstEmptyRuleColumnSearch(), { flush: "post" });
|
||||
|
||||
onUnmounted(onResizeEnd);
|
||||
@@ -160,7 +175,7 @@ onUnmounted(onResizeEnd);
|
||||
<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">
|
||||
<template v-if="filterEditorView === 'quick'">
|
||||
<Popover :open="filterBuilderOpen" @update:open="emit('update:filterBuilderOpen', $event)">
|
||||
<Popover :open="filterBuilderOpen" @update:open="updateQuickFilterBuilderOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
@@ -212,6 +227,7 @@ onUnmounted(onResizeEnd);
|
||||
:column-search="columnSearch"
|
||||
:disabled="!canUseWhereSearch"
|
||||
:show-header="false"
|
||||
:value-suggestions="valueSuggestions"
|
||||
@add="emit('addRule')"
|
||||
@apply-only="emit('applyOnly', $event)"
|
||||
@apply="emit('applyFilters')"
|
||||
@@ -221,6 +237,13 @@ onUnmounted(onResizeEnd);
|
||||
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
|
||||
@update-rule="updateRule"
|
||||
@update:column-search="emit('update:columnSearch', $event)"
|
||||
@open-value-suggestions="(id, target) => emit('openValueSuggestions', id, target)"
|
||||
@close-value-suggestions="emit('closeValueSuggestions')"
|
||||
@update-value-suggestion-search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select-value-suggestion="emit('selectValueSuggestion', $event)"
|
||||
@toggle-value-suggestion="emit('toggleValueSuggestion', $event)"
|
||||
@toggle-all-value-suggestions="emit('toggleAllValueSuggestions')"
|
||||
@apply-value-suggestions="emit('applyValueSuggestions')"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -7,6 +7,8 @@ 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 type { DataGridDistinctValueSuggestionState, DataGridDistinctValueSuggestionTarget } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import { DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MAX, DATA_GRID_TEXT_FILTER_PANEL_HEIGHT_MIN } from "@/lib/dataGrid/dataGridTextFilterPanel";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -19,6 +21,7 @@ const props = defineProps<{
|
||||
columnSearch: string;
|
||||
applyOnlyBusy?: boolean;
|
||||
disabled?: boolean;
|
||||
valueSuggestions?: DataGridDistinctValueSuggestionState;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -34,6 +37,13 @@ const emit = defineEmits<{
|
||||
removeRule: [id: string];
|
||||
moveRule: [id: string, targetIndex: number];
|
||||
updateRule: [id: string, patch: Partial<DataGridStructuredFilterRule>];
|
||||
openValueSuggestions: [id: string, target: DataGridDistinctValueSuggestionTarget];
|
||||
closeValueSuggestions: [];
|
||||
updateValueSuggestionSearch: [value: string];
|
||||
selectValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleValueSuggestion: [option: DataGridLocalFilterOption];
|
||||
toggleAllValueSuggestions: [];
|
||||
applyValueSuggestions: [];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -108,6 +118,7 @@ function addRuleFromBlankArea(event: KeyboardEvent) {
|
||||
|
||||
onMounted(() => emit("ensureRule"));
|
||||
onBeforeUnmount(() => {
|
||||
emit("closeValueSuggestions");
|
||||
stopResizeListeners();
|
||||
restoreDocumentInteraction();
|
||||
});
|
||||
@@ -153,6 +164,7 @@ watch(
|
||||
:mode-options="modeOptions"
|
||||
:column-search="columnSearch"
|
||||
:disabled="disabled"
|
||||
:value-suggestions="valueSuggestions"
|
||||
layout="text"
|
||||
:show-header="false"
|
||||
:show-footer="false"
|
||||
@@ -165,6 +177,13 @@ watch(
|
||||
@move="(id, targetIndex) => emit('moveRule', id, targetIndex)"
|
||||
@update-rule="(id, patch) => emit('updateRule', id, patch)"
|
||||
@update:column-search="emit('update:columnSearch', $event)"
|
||||
@open-value-suggestions="(id, target) => emit('openValueSuggestions', id, target)"
|
||||
@close-value-suggestions="emit('closeValueSuggestions')"
|
||||
@update-value-suggestion-search="emit('updateValueSuggestionSearch', $event)"
|
||||
@select-value-suggestion="emit('selectValueSuggestion', $event)"
|
||||
@toggle-value-suggestion="emit('toggleValueSuggestion', $event)"
|
||||
@toggle-all-value-suggestions="emit('toggleAllValueSuggestions')"
|
||||
@apply-value-suggestions="emit('applyValueSuggestions')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ import DataGridCellDetailPanel from "@/components/grid/DataGridCellDetailPanel.v
|
||||
import DataGrid from "@/components/grid/DataGrid.vue";
|
||||
import DataGridColumnHeader from "@/components/grid/DataGridColumnHeader.vue";
|
||||
import DataGridCopyColumnNamesDialog from "@/components/grid/DataGridCopyColumnNamesDialog.vue";
|
||||
import DataGridDistinctValuePopover from "@/components/grid/DataGridDistinctValuePopover.vue";
|
||||
import DataGridFilterBuilder from "@/components/grid/DataGridFilterBuilder.vue";
|
||||
import DataGridFilterWorkbench from "@/components/grid/DataGridFilterWorkbench.vue";
|
||||
import DataGridTextFilterWorkbench from "@/components/grid/DataGridTextFilterWorkbench.vue";
|
||||
@@ -1141,6 +1142,50 @@ describe("DataGridQueryControls", () => {
|
||||
expect(popoverContent.props["collision-padding"]).toBe(8);
|
||||
});
|
||||
|
||||
it("forwards database value suggestions in the quick filter popover", () => {
|
||||
const openValueSuggestions = vi.fn();
|
||||
const closeValueSuggestions = vi.fn();
|
||||
const mounted = mountComponent(DataGridQueryControls, {
|
||||
whereInput: "",
|
||||
orderByInput: "",
|
||||
columns: ["status"],
|
||||
conditionColumns: ["status"],
|
||||
historyScope: {},
|
||||
canUseWhereSearch: true,
|
||||
compact: false,
|
||||
leadingBorder: false,
|
||||
filterBuilderOpen: true,
|
||||
filterEditorView: "quick",
|
||||
filterButtonActive: false,
|
||||
filterButtonCount: 0,
|
||||
hasLocalColumnFilters: false,
|
||||
localFilterCount: 0,
|
||||
localFilterSummaries: [],
|
||||
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
filteredColumns: ["status"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
valueSuggestions: { search: "", options: [], loading: false, error: "", limited: false, limit: 1000, selectedKeys: new Set<string>() },
|
||||
applyWhere: vi.fn(),
|
||||
applyOrderBy: vi.fn(),
|
||||
clearOrderBy: vi.fn(),
|
||||
onOpenValueSuggestions: openValueSuggestions,
|
||||
onCloseValueSuggestions: closeValueSuggestions,
|
||||
});
|
||||
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
|
||||
const suggestionPopover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover" && node.props.open === false);
|
||||
const openHandler = suggestionPopover.props["onUpdate:open"];
|
||||
for (const callback of Array.isArray(openHandler) ? openHandler : [openHandler]) callback(true);
|
||||
|
||||
expect(openValueSuggestions).toHaveBeenCalledWith("r1", "value");
|
||||
|
||||
const quickFilterPopover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover" && node.props.open === true);
|
||||
const closeHandler = quickFilterPopover.props["onUpdate:open"];
|
||||
for (const callback of Array.isArray(closeHandler) ? closeHandler : [closeHandler]) callback(false);
|
||||
expect(closeValueSuggestions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps filter actions available in the popover", () => {
|
||||
const addRule = vi.fn();
|
||||
const clearFilters = vi.fn();
|
||||
@@ -1252,7 +1297,121 @@ describe("DataGridQueryControls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridDistinctValuePopover", () => {
|
||||
it("renders counts and distinct loading, error, and limit states", async () => {
|
||||
const mounted = mountComponent(DataGridDistinctValuePopover, {
|
||||
open: true,
|
||||
search: "",
|
||||
options: [{ key: "str:open", label: "open", count: 12, value: "open" }],
|
||||
loading: true,
|
||||
error: "",
|
||||
limited: false,
|
||||
limit: 1000,
|
||||
});
|
||||
expect(hostText(mounted.root)).toContain("grid.loadingValues");
|
||||
expect(hostText(mounted.root)).toContain("open");
|
||||
expect(hostText(mounted.root)).toContain("12");
|
||||
|
||||
await mounted.setProps({ loading: false, error: "query failed", options: [] });
|
||||
expect(hostText(mounted.root)).toContain("query failed");
|
||||
expect(hostText(mounted.root)).not.toContain("grid.noSearchResults");
|
||||
|
||||
await mounted.setProps({ error: "", limited: true });
|
||||
expect(hostText(mounted.root)).toContain("grid.serverValuesLimited");
|
||||
});
|
||||
|
||||
it("selects all displayed candidates and matches the database-value filter checkbox state", async () => {
|
||||
const toggleAll = vi.fn();
|
||||
const options = [
|
||||
{ key: "str:open", label: "open", count: 12, value: "open" },
|
||||
{ key: "str:closed", label: "closed", count: 4, value: "closed" },
|
||||
];
|
||||
const mounted = mountComponent(DataGridDistinctValuePopover, {
|
||||
open: true,
|
||||
search: "",
|
||||
options,
|
||||
loading: false,
|
||||
error: "",
|
||||
limited: false,
|
||||
limit: 1000,
|
||||
multiple: true,
|
||||
selectedKeys: new Set<string>(),
|
||||
onToggleAll: toggleAll,
|
||||
});
|
||||
const findSelectAll = () => findOne(mounted.root, (node) => node.props["data-distinct-value-select-all"] === "");
|
||||
|
||||
expect(findSelectAll().props["aria-checked"]).toBe(false);
|
||||
dispatch(findSelectAll(), "click");
|
||||
expect(toggleAll).toHaveBeenCalledOnce();
|
||||
|
||||
await mounted.setProps({ selectedKeys: new Set(["str:open"]) });
|
||||
expect(findSelectAll().props["aria-checked"]).toBe(false);
|
||||
|
||||
await mounted.setProps({ selectedKeys: new Set(options.map((option) => option.key)) });
|
||||
expect(findSelectAll().props["aria-checked"]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DataGridFilterWorkbench", () => {
|
||||
it("shows database value suggestions only for enabled value rules in the conditions panel", async () => {
|
||||
const openSuggestions = vi.fn();
|
||||
const valueSuggestions = {
|
||||
ruleId: undefined,
|
||||
target: undefined,
|
||||
search: "",
|
||||
options: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
limited: false,
|
||||
limit: 1000,
|
||||
selectedKeys: new Set<string>(),
|
||||
};
|
||||
const mounted = mountComponent(DataGridFilterWorkbench, {
|
||||
sqlPreview: "",
|
||||
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["status"],
|
||||
filteredColumns: ["status"],
|
||||
modeOptions: [
|
||||
{ value: "equals", labelKey: "equals" },
|
||||
{ value: "is-null", labelKey: "is-null" },
|
||||
],
|
||||
columnSearch: "",
|
||||
valueSuggestions,
|
||||
onOpenValueSuggestions: openSuggestions,
|
||||
});
|
||||
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
|
||||
const openHandler = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover").props["onUpdate:open"];
|
||||
for (const callback of Array.isArray(openHandler) ? openHandler : [openHandler]) callback(true);
|
||||
expect(openSuggestions).toHaveBeenCalledWith("r1", "value");
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND", disabled: true }] });
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(0);
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "is-null", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(0);
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "between", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(2);
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "in", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("exposes value suggestions in the reusable popover builder when provided", () => {
|
||||
const mounted = mountComponent(DataGridFilterBuilder, {
|
||||
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["status"],
|
||||
filteredColumns: ["status"],
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
layout: "popover",
|
||||
valueSuggestions: { search: "", options: [], loading: false, error: "", limited: false, limit: 1000, selectedKeys: new Set<string>() },
|
||||
});
|
||||
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("scrolls to the newest rule when a condition is added", async () => {
|
||||
const requestAnimationFrame = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
|
||||
callback(0);
|
||||
@@ -1378,6 +1537,57 @@ describe("DataGridFilterWorkbench", () => {
|
||||
});
|
||||
|
||||
describe("DataGridTextFilterWorkbench", () => {
|
||||
it("shows and forwards database value suggestions in the text filter panel", async () => {
|
||||
const openValueSuggestions = vi.fn();
|
||||
const closeValueSuggestions = vi.fn();
|
||||
const valueSuggestions = {
|
||||
ruleId: undefined,
|
||||
target: undefined,
|
||||
search: "",
|
||||
options: [],
|
||||
loading: false,
|
||||
error: "",
|
||||
limited: false,
|
||||
limit: 1000,
|
||||
selectedKeys: new Set<string>(),
|
||||
};
|
||||
const mounted = mountComponent(DataGridTextFilterWorkbench, {
|
||||
height: 168,
|
||||
sqlPreview: "",
|
||||
rules: [{ id: "r1", columnName: "status", mode: "equals", rawValue: "", rawEndValue: "", conjunction: "AND" }],
|
||||
columns: ["status"],
|
||||
filteredColumns: ["status"],
|
||||
modeOptions: [
|
||||
{ value: "equals", labelKey: "equals" },
|
||||
{ value: "between", labelKey: "between" },
|
||||
{ value: "in", labelKey: "in" },
|
||||
],
|
||||
columnSearch: "",
|
||||
valueSuggestions,
|
||||
onOpenValueSuggestions: openValueSuggestions,
|
||||
onCloseValueSuggestions: closeValueSuggestions,
|
||||
});
|
||||
|
||||
const suggestionButtons = () => findAll(mounted.root, (node) => node.type === "button" && node.props.title === "grid.databaseValueFilter");
|
||||
expect(suggestionButtons()).toHaveLength(1);
|
||||
expect(String(suggestionButtons()[0].props.class)).toContain("h-6");
|
||||
|
||||
const suggestionPopover = findOne(mounted.root, (node) => node.props["data-stub"] === "Popover" && node.props.open === false);
|
||||
const openHandler = suggestionPopover.props["onUpdate:open"];
|
||||
for (const callback of Array.isArray(openHandler) ? openHandler : [openHandler]) callback(true);
|
||||
expect(openValueSuggestions).toHaveBeenCalledWith("r1", "value");
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "between", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
|
||||
expect(suggestionButtons()).toHaveLength(2);
|
||||
|
||||
await mounted.setProps({ rules: [{ id: "r1", columnName: "status", mode: "in", rawValue: "", rawEndValue: "", conjunction: "AND" }] });
|
||||
expect(suggestionButtons()).toHaveLength(1);
|
||||
expect(findAll(mounted.root, (node) => node.props["data-distinct-value-select-all"] === "")).toHaveLength(1);
|
||||
|
||||
mounted.unmount();
|
||||
expect(closeValueSuggestions).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("exposes a persisted keyboard-resizable text filter panel", async () => {
|
||||
const ensureRule = vi.fn();
|
||||
const updateHeight = vi.fn();
|
||||
|
||||
+2
-6
@@ -3,7 +3,6 @@
|
||||
import { createApp, defineComponent, h, ref, computed } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDataGridColumnFilters, type DataGridColumnFilterState, type DataGridLocalFilterDraft } from "@/composables/useDataGridColumnFilters";
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
@@ -17,11 +16,6 @@ function mountColumnFilters(rows: CellValue[][]) {
|
||||
localFilterOpenColumn: ref<number | null>(null),
|
||||
localFilterSearch: ref(""),
|
||||
localFilterDraft: ref<DataGridLocalFilterDraft | null>(null),
|
||||
serverFilterLoading: ref(false),
|
||||
serverFilterError: ref(""),
|
||||
serverFilterOptions: ref<DataGridLocalFilterOption[]>([]),
|
||||
serverFilterLimited: ref(false),
|
||||
serverFilterValueByKey: ref(new Map()),
|
||||
serverColumnFilters: ref({}),
|
||||
};
|
||||
const result: QueryResult = { columns: ["status"], rows };
|
||||
@@ -36,6 +30,7 @@ function mountColumnFilters(rows: CellValue[][]) {
|
||||
getConnectionId: () => undefined,
|
||||
getSchema: () => undefined,
|
||||
getExecutionDatabase: () => "",
|
||||
scopeIdentity: computed(() => "test"),
|
||||
resolvedDatabaseType: computed(() => undefined),
|
||||
canUseWhereSearch: computed(() => false),
|
||||
canUseServerColumnFilter: computed(() => false),
|
||||
@@ -44,6 +39,7 @@ function mountColumnFilters(rows: CellValue[][]) {
|
||||
whereFilterInput: ref(""),
|
||||
getConnectionConfig: () => undefined,
|
||||
getIdentifierQuote: () => undefined,
|
||||
getGlobalQueryTimeoutSecs: () => undefined,
|
||||
getNewRows: () => [],
|
||||
getRowData: (row) => row,
|
||||
formatValue: (value) => String(value),
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { computed, createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { buildDataGridColumnDistinctValuesSql } from "@/lib/dataGrid/dataGridSql";
|
||||
import { DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT, useDataGridDistinctValueLoader } from "@/composables/useDataGridDistinctValueLoader";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
vi.mock("@/lib/backend/api", () => ({ executeQuery: vi.fn() }));
|
||||
vi.mock("@/lib/dataGrid/dataGridSql", () => ({ buildDataGridColumnDistinctValuesSql: vi.fn(async () => "SELECT DISTINCT VALUES") }));
|
||||
|
||||
const disposers: Array<() => void> = [];
|
||||
|
||||
function mountLoader() {
|
||||
const identity = ref("table-1");
|
||||
const connectionId = ref<string | undefined>("connection-1");
|
||||
let loader: ReturnType<typeof useDataGridDistinctValueLoader> | undefined;
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup() {
|
||||
loader = useDataGridDistinctValueLoader({
|
||||
scopeIdentity: computed(() => identity.value),
|
||||
getConnectionId: () => connectionId.value,
|
||||
getExecutionDatabase: () => "app",
|
||||
getSchema: () => "public",
|
||||
getDatabaseType: () => "postgres",
|
||||
getConnectionConfig: () => ({
|
||||
driver_profile: "native",
|
||||
query_timeout_secs: 12,
|
||||
query_timeout_inherit: false,
|
||||
}),
|
||||
getIdentifierQuote: () => undefined,
|
||||
getGlobalQueryTimeoutSecs: () => 60,
|
||||
waitForTableMeta: async () => ({
|
||||
schema: "public",
|
||||
tableName: "users",
|
||||
columns: [{ name: "status", data_type: "varchar", is_nullable: true }],
|
||||
}),
|
||||
formatValue: (value) => String(value),
|
||||
});
|
||||
return () => h("div");
|
||||
},
|
||||
}),
|
||||
);
|
||||
app.mount(document.createElement("div"));
|
||||
disposers.push(() => app.unmount());
|
||||
if (!loader) throw new Error("loader was not mounted");
|
||||
return { loader, identity, connectionId };
|
||||
}
|
||||
|
||||
function result(rows: QueryResult["rows"]): QueryResult {
|
||||
return { columns: ["dbx_value", "dbx_count"], rows };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dispose of disposers.splice(0)) dispose();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useDataGridDistinctValueLoader", () => {
|
||||
it("keeps the default candidate limit at 1000", () => {
|
||||
expect(DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT).toBe(1000);
|
||||
});
|
||||
|
||||
it("marks results that reach the default 1000-candidate limit", async () => {
|
||||
vi.mocked(api.executeQuery).mockResolvedValue(result(Array.from({ length: 1000 }, (_, index) => [`value-${index}`, 1])));
|
||||
const { loader } = mountLoader();
|
||||
|
||||
await loader.load({ columnIndex: 0, columnName: "status" });
|
||||
|
||||
expect(loader.options.value).toHaveLength(1000);
|
||||
expect(loader.limited.value).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the requested limit, keeps NULL, caches independent results, and resets on table changes", async () => {
|
||||
const rows: QueryResult["rows"] = Array.from({ length: 101 }, (_, index) => [`value-${index}`, 101 - index]);
|
||||
rows.splice(4, 0, [null, 9]);
|
||||
vi.mocked(api.executeQuery).mockResolvedValue(result(rows));
|
||||
const { loader, identity } = mountLoader();
|
||||
|
||||
await loader.load({ columnIndex: 0, columnName: "status", limit: 100, includeCounts: true });
|
||||
|
||||
expect(buildDataGridColumnDistinctValuesSql).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
columnName: "status",
|
||||
limit: 100,
|
||||
includeCounts: true,
|
||||
excludeNulls: false,
|
||||
}),
|
||||
);
|
||||
expect(vi.mocked(buildDataGridColumnDistinctValuesSql).mock.calls[0]?.[0]).not.toHaveProperty("whereInput");
|
||||
expect(api.executeQuery).toHaveBeenCalledWith(
|
||||
"connection-1",
|
||||
"app",
|
||||
"SELECT DISTINCT VALUES",
|
||||
"public",
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
maxRows: 100,
|
||||
fetchSize: 100,
|
||||
pageSize: 100,
|
||||
timeoutSecs: 12,
|
||||
}),
|
||||
);
|
||||
expect(loader.options.value).toHaveLength(100);
|
||||
expect(loader.options.value.some((option) => option.value === null)).toBe(true);
|
||||
expect(loader.limited.value).toBe(true);
|
||||
|
||||
await loader.load({ columnIndex: 0, columnName: "status", limit: 100, includeCounts: true });
|
||||
expect(api.executeQuery).toHaveBeenCalledTimes(1);
|
||||
|
||||
identity.value = "table-2";
|
||||
await nextTick();
|
||||
expect(loader.options.value).toEqual([]);
|
||||
await loader.load({ columnIndex: 0, columnName: "status", limit: 100, includeCounts: true });
|
||||
expect(api.executeQuery).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("debounces searches and ignores a superseded response", async () => {
|
||||
let resolveFirst: ((value: QueryResult) => void) | undefined;
|
||||
vi.mocked(api.executeQuery)
|
||||
.mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve)))
|
||||
.mockResolvedValueOnce(result([["new", 2]]));
|
||||
const { loader } = mountLoader();
|
||||
|
||||
const first = loader.load({ columnIndex: 0, columnName: "status", searchValue: "old" });
|
||||
await vi.waitFor(() => expect(api.executeQuery).toHaveBeenCalledTimes(1));
|
||||
vi.useFakeTimers();
|
||||
loader.schedule({ columnIndex: 0, columnName: "status", searchValue: "new" });
|
||||
await vi.advanceTimersByTimeAsync(299);
|
||||
expect(api.executeQuery).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(api.executeQuery).toHaveBeenCalledTimes(2);
|
||||
expect(loader.options.value.map((option) => option.value)).toEqual(["new"]);
|
||||
|
||||
resolveFirst?.(result([["old", 9]]));
|
||||
await first;
|
||||
expect(loader.options.value.map((option) => option.value)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("clears the loading state when the connection disappears during a debounced search", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { loader, connectionId } = mountLoader();
|
||||
connectionId.value = undefined;
|
||||
|
||||
loader.schedule({ columnIndex: 0, columnName: "status", searchValue: "open" });
|
||||
expect(loader.loading.value).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(loader.loading.value).toBe(false);
|
||||
expect(api.executeQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expires cached results after 30 seconds", async () => {
|
||||
let now = 1_000;
|
||||
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
vi.mocked(api.executeQuery).mockResolvedValue(result([["open", 1]]));
|
||||
const { loader } = mountLoader();
|
||||
const request = { columnIndex: 0, columnName: "status", searchValue: "open" };
|
||||
|
||||
await loader.load(request);
|
||||
now += 29_999;
|
||||
await loader.load(request);
|
||||
expect(api.executeQuery).toHaveBeenCalledTimes(1);
|
||||
|
||||
now += 2;
|
||||
await loader.load(request);
|
||||
expect(api.executeQuery).toHaveBeenCalledTimes(2);
|
||||
nowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("surfaces errors without retaining stale options and can recover", async () => {
|
||||
vi.mocked(api.executeQuery)
|
||||
.mockResolvedValueOnce(result([["ready", 1]]))
|
||||
.mockRejectedValueOnce(new Error("query failed"))
|
||||
.mockResolvedValueOnce(result([["recovered", 1]]));
|
||||
const { loader } = mountLoader();
|
||||
await loader.load({ columnIndex: 0, columnName: "status" });
|
||||
await loader.load({ columnIndex: 0, columnName: "status", searchValue: "missing" });
|
||||
expect(loader.options.value).toEqual([]);
|
||||
expect(loader.error.value).toBe("query failed");
|
||||
expect(loader.loading.value).toBe(false);
|
||||
await loader.load({ columnIndex: 0, columnName: "status", searchValue: "again" });
|
||||
expect(loader.error.value).toBe("");
|
||||
expect(loader.options.value.map((option) => option.value)).toEqual(["recovered"]);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import { computed, onUnmounted, ref, watch, type ComputedRef, type Ref } from "vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { computed, ref, watch, type ComputedRef, type Ref } from "vue";
|
||||
import { buildColumnValueFilterCondition, buildColumnValuesFilterCondition, parseFilterValue, removeColumnValueFilterCondition, replaceColumnValueFilterCondition, appendColumnValueFilterCondition } from "@/lib/dataGrid/dataGridColumnFilter";
|
||||
import { buildDataGridColumnDistinctValuesSql } from "@/lib/dataGrid/dataGridSql";
|
||||
import { buildDataGridLocalFilterOptions, sortDataGridLocalFilterOptions, type DataGridLocalFilterSort, dataGridLocalFilterKey, dataGridLocalFilterLabel, rowMatchesDataGridLocalColumnFilters, type DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import type { DataGridCachedServerColumnFilter } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import type { ColumnInfo, DatabaseType, QueryResult } from "@/types/database";
|
||||
import { DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT, useDataGridDistinctValueLoader } from "@/composables/useDataGridDistinctValueLoader";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
export type DataGridLocalFilterMode = "local" | "server";
|
||||
|
||||
@@ -21,11 +20,6 @@ export interface DataGridColumnFilterState {
|
||||
localFilterOpenColumn: Ref<number | null>;
|
||||
localFilterSearch: Ref<string>;
|
||||
localFilterDraft: Ref<DataGridLocalFilterDraft | null>;
|
||||
serverFilterLoading: Ref<boolean>;
|
||||
serverFilterError: Ref<string>;
|
||||
serverFilterOptions: Ref<DataGridLocalFilterOption[]>;
|
||||
serverFilterLimited: Ref<boolean>;
|
||||
serverFilterValueByKey: Ref<Map<string, CellValue>>;
|
||||
serverColumnFilters: Ref<Record<number, DataGridCachedServerColumnFilter>>;
|
||||
}
|
||||
|
||||
@@ -37,10 +31,6 @@ interface TableMetadata {
|
||||
columns: ColumnInfo[];
|
||||
}
|
||||
|
||||
interface ConnectionConfig {
|
||||
driver_profile?: string;
|
||||
}
|
||||
|
||||
export interface UseDataGridColumnFiltersOptions {
|
||||
state: DataGridColumnFilterState;
|
||||
getResult: () => QueryResult;
|
||||
@@ -48,14 +38,16 @@ export interface UseDataGridColumnFiltersOptions {
|
||||
getConnectionId: () => string | undefined;
|
||||
getSchema: () => string | undefined;
|
||||
getExecutionDatabase: () => string;
|
||||
scopeIdentity: ComputedRef<string>;
|
||||
resolvedDatabaseType: ComputedRef<DatabaseType | undefined>;
|
||||
canUseWhereSearch: ComputedRef<boolean>;
|
||||
canUseServerColumnFilter: ComputedRef<boolean>;
|
||||
structuredFilterCount: ComputedRef<number>;
|
||||
hasStructuredFilters: ComputedRef<boolean>;
|
||||
whereFilterInput: Ref<string>;
|
||||
getConnectionConfig: () => ConnectionConfig | undefined;
|
||||
getConnectionConfig: () => Pick<ConnectionConfig, "driver_profile" | "query_timeout_secs" | "query_timeout_inherit"> | undefined;
|
||||
getIdentifierQuote: () => string | undefined;
|
||||
getGlobalQueryTimeoutSecs: () => number | undefined;
|
||||
getNewRows: () => readonly (readonly CellValue[])[];
|
||||
getRowData: (row: CellValue[], sourceIndex: number) => readonly CellValue[];
|
||||
formatValue: (value: CellValue, columnIndex: number) => string;
|
||||
@@ -67,12 +59,28 @@ export interface UseDataGridColumnFiltersOptions {
|
||||
emitLocalFiltersChange: (filters: Record<string, string[]>) => void;
|
||||
}
|
||||
|
||||
export const DATA_GRID_SERVER_COLUMN_FILTER_LIMIT = 1000;
|
||||
const SERVER_COLUMN_FILTER_DEBOUNCE_MS = 300;
|
||||
export const DATA_GRID_SERVER_COLUMN_FILTER_LIMIT = DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT;
|
||||
|
||||
export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOptions) {
|
||||
const { state } = options;
|
||||
const localFilterSort = ref<DataGridLocalFilterSort>({ field: "value", direction: "asc" });
|
||||
const distinctValueLoader = useDataGridDistinctValueLoader({
|
||||
scopeIdentity: options.scopeIdentity,
|
||||
getConnectionId: options.getConnectionId,
|
||||
getExecutionDatabase: options.getExecutionDatabase,
|
||||
getSchema: options.getSchema,
|
||||
getDatabaseType: () => options.resolvedDatabaseType.value,
|
||||
getConnectionConfig: options.getConnectionConfig,
|
||||
getIdentifierQuote: options.getIdentifierQuote,
|
||||
getGlobalQueryTimeoutSecs: options.getGlobalQueryTimeoutSecs,
|
||||
waitForTableMeta: options.waitForTableMeta,
|
||||
formatValue: options.formatValue,
|
||||
});
|
||||
const serverFilterLoading = distinctValueLoader.loading;
|
||||
const serverFilterError = distinctValueLoader.error;
|
||||
const serverFilterOptions = distinctValueLoader.options;
|
||||
const serverFilterLimited = distinctValueLoader.limited;
|
||||
const serverFilterValueByKey = distinctValueLoader.valueByKey;
|
||||
|
||||
function toggleLocalFilterSort(field: DataGridLocalFilterSort["field"]) {
|
||||
if (state.localFilterDraft.value?.mode !== "local") return;
|
||||
@@ -83,9 +91,6 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
|
||||
};
|
||||
}
|
||||
|
||||
let serverFilterRequestId = 0;
|
||||
let serverFilterSearchTimer: ReturnType<typeof window.setTimeout> | undefined;
|
||||
|
||||
watch(
|
||||
state.localColumnFilters,
|
||||
(filters) => {
|
||||
@@ -142,12 +147,12 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
|
||||
const rowMatchesLocalColumnFilters = (data: CellValue[]) => rowMatchesDataGridLocalColumnFilters(data, state.localColumnFilters.value);
|
||||
|
||||
const localFilterAllOptions = computed(() => {
|
||||
if (state.localFilterDraft.value?.mode === "server") return state.serverFilterOptions.value;
|
||||
if (state.localFilterDraft.value?.mode === "server") return serverFilterOptions.value;
|
||||
const columnIndex = state.localFilterDraft.value?.columnIndex;
|
||||
return columnIndex === undefined ? [] : buildLocalFilterOptions(columnIndex);
|
||||
});
|
||||
const localFilterOptions = computed(() => {
|
||||
if (state.localFilterDraft.value?.mode === "server") return state.serverFilterOptions.value;
|
||||
if (state.localFilterDraft.value?.mode === "server") return serverFilterOptions.value;
|
||||
const query = state.localFilterSearch.value.trim().toLowerCase();
|
||||
const matchingOptions = localFilterAllOptions.value.filter((option) => !query || option.label.toLowerCase().includes(query));
|
||||
// Sort before limiting so frequent values outside the default first 500 remain discoverable.
|
||||
@@ -174,19 +179,6 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
|
||||
return !localFilterAllOptions.value.some((option) => option.label.toLowerCase() === normalized);
|
||||
});
|
||||
|
||||
function resetServerFilterState() {
|
||||
serverFilterRequestId++;
|
||||
if (serverFilterSearchTimer !== undefined) {
|
||||
window.clearTimeout(serverFilterSearchTimer);
|
||||
serverFilterSearchTimer = undefined;
|
||||
}
|
||||
state.serverFilterLoading.value = false;
|
||||
state.serverFilterError.value = "";
|
||||
state.serverFilterOptions.value = [];
|
||||
state.serverFilterLimited.value = false;
|
||||
state.serverFilterValueByKey.value = new Map();
|
||||
}
|
||||
|
||||
function openLocalFilter(columnIndex: number, requestedMode: DataGridLocalFilterMode = "local") {
|
||||
options.onOpen?.();
|
||||
localFilterSort.value = { field: "value", direction: "asc" };
|
||||
@@ -200,7 +192,7 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
|
||||
touched: false,
|
||||
};
|
||||
state.localFilterOpenColumn.value = columnIndex;
|
||||
resetServerFilterState();
|
||||
distinctValueLoader.reset();
|
||||
if (mode === "server") void loadServerFilterValues(columnIndex, "");
|
||||
}
|
||||
|
||||
@@ -209,24 +201,7 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
|
||||
state.localFilterOpenColumn.value = null;
|
||||
state.localFilterDraft.value = null;
|
||||
state.localFilterSearch.value = "";
|
||||
resetServerFilterState();
|
||||
}
|
||||
|
||||
function serverFilterOptionFromRow(row: QueryResult["rows"][number], columnIndex: number): DataGridLocalFilterOption {
|
||||
const value = (row[0] ?? null) as CellValue;
|
||||
const countValue = Number(row[1]);
|
||||
return { key: dataGridLocalFilterKey(value), label: localFilterLabel(value, columnIndex), count: Number.isFinite(countValue) ? countValue : null, value };
|
||||
}
|
||||
|
||||
function serverFilterOptionsFromResult(result: QueryResult, columnIndex: number): DataGridLocalFilterOption[] {
|
||||
const byKey = new Map<string, DataGridLocalFilterOption>();
|
||||
for (const row of result.rows) {
|
||||
const option = serverFilterOptionFromRow(row, columnIndex);
|
||||
const current = byKey.get(option.key);
|
||||
if (current) current.count = (current.count ?? 0) + (option.count ?? 0);
|
||||
else byKey.set(option.key, option);
|
||||
}
|
||||
return [...byKey.values()];
|
||||
distinctValueLoader.reset();
|
||||
}
|
||||
|
||||
function syncServerFilterDraft(columnIndex: number, filterOptions: DataGridLocalFilterOption[]) {
|
||||
@@ -236,64 +211,39 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
|
||||
state.localFilterDraft.value = { ...draft, values: new Set(activeFilter?.keys ?? filterOptions.map((option) => option.key)) };
|
||||
}
|
||||
|
||||
watch(serverFilterOptions, (filterOptions) => {
|
||||
const draft = state.localFilterDraft.value;
|
||||
if (draft?.mode === "server" && state.localFilterOpenColumn.value === draft.columnIndex) syncServerFilterDraft(draft.columnIndex, filterOptions);
|
||||
});
|
||||
|
||||
async function loadServerFilterValues(columnIndex: number, searchValue: string) {
|
||||
const connectionId = options.getConnectionId();
|
||||
if (!options.canUseServerColumnFilter.value || !connectionId) return;
|
||||
if (!options.canUseServerColumnFilter.value || !options.getConnectionId()) return;
|
||||
const columnName = options.getResult().columns[columnIndex];
|
||||
if (!columnName) return;
|
||||
const requestId = ++serverFilterRequestId;
|
||||
state.serverFilterLoading.value = true;
|
||||
state.serverFilterError.value = "";
|
||||
state.serverFilterLimited.value = false;
|
||||
try {
|
||||
const tableMeta = await options.waitForTableMeta();
|
||||
if (!tableMeta) return;
|
||||
const columnInfo = tableMeta.columns.find((column) => column.name === columnName);
|
||||
const sql = await buildDataGridColumnDistinctValuesSql({
|
||||
databaseType: options.resolvedDatabaseType.value,
|
||||
driverProfile: options.getConnectionConfig()?.driver_profile,
|
||||
identifierQuote: options.getIdentifierQuote(),
|
||||
catalog: tableMeta.catalog,
|
||||
database: tableMeta.database,
|
||||
schema: tableMeta.schema,
|
||||
tableName: tableMeta.tableName,
|
||||
columnName,
|
||||
columnInfo,
|
||||
searchValue: searchValue.trim() || undefined,
|
||||
limit: DATA_GRID_SERVER_COLUMN_FILTER_LIMIT,
|
||||
includeCounts: true,
|
||||
});
|
||||
const result = await api.executeQuery(connectionId, options.getExecutionDatabase(), sql, tableMeta.schema ?? options.getSchema(), undefined, {
|
||||
maxRows: DATA_GRID_SERVER_COLUMN_FILTER_LIMIT,
|
||||
fetchSize: DATA_GRID_SERVER_COLUMN_FILTER_LIMIT,
|
||||
pageSize: DATA_GRID_SERVER_COLUMN_FILTER_LIMIT,
|
||||
});
|
||||
if (requestId !== serverFilterRequestId || state.localFilterOpenColumn.value !== columnIndex) return;
|
||||
const filterOptions = serverFilterOptionsFromResult(result, columnIndex);
|
||||
const nextValueByKey = new Map(state.serverFilterValueByKey.value);
|
||||
for (const option of filterOptions) nextValueByKey.set(option.key, option.value);
|
||||
state.serverFilterValueByKey.value = nextValueByKey;
|
||||
state.serverFilterOptions.value = filterOptions;
|
||||
state.serverFilterLimited.value = result.truncated === true || result.rows.length >= DATA_GRID_SERVER_COLUMN_FILTER_LIMIT;
|
||||
syncServerFilterDraft(columnIndex, filterOptions);
|
||||
} catch (error: any) {
|
||||
if (requestId !== serverFilterRequestId) return;
|
||||
state.serverFilterOptions.value = [];
|
||||
state.serverFilterError.value = String(error?.message || error);
|
||||
} finally {
|
||||
if (requestId === serverFilterRequestId) state.serverFilterLoading.value = false;
|
||||
}
|
||||
await distinctValueLoader.load({
|
||||
columnIndex,
|
||||
columnName,
|
||||
searchValue,
|
||||
limit: DATA_GRID_SERVER_COLUMN_FILTER_LIMIT,
|
||||
includeCounts: true,
|
||||
});
|
||||
if (state.localFilterOpenColumn.value === columnIndex) syncServerFilterDraft(columnIndex, serverFilterOptions.value);
|
||||
}
|
||||
|
||||
watch(state.localFilterSearch, (value) => {
|
||||
const draft = state.localFilterDraft.value;
|
||||
if (!draft || draft.mode !== "server" || state.localFilterOpenColumn.value !== draft.columnIndex) return;
|
||||
if (serverFilterSearchTimer !== undefined) window.clearTimeout(serverFilterSearchTimer);
|
||||
serverFilterSearchTimer = window.setTimeout(() => void loadServerFilterValues(draft.columnIndex, value), SERVER_COLUMN_FILTER_DEBOUNCE_MS);
|
||||
const columnName = options.getResult().columns[draft.columnIndex];
|
||||
if (!columnName) return;
|
||||
distinctValueLoader.schedule({
|
||||
columnIndex: draft.columnIndex,
|
||||
columnName,
|
||||
searchValue: value,
|
||||
limit: DATA_GRID_SERVER_COLUMN_FILTER_LIMIT,
|
||||
includeCounts: true,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(resetServerFilterState);
|
||||
|
||||
function toggleLocalFilterValue(key: string) {
|
||||
const draft = state.localFilterDraft.value;
|
||||
if (!draft) return;
|
||||
@@ -339,13 +289,13 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
|
||||
closeLocalFilter();
|
||||
return;
|
||||
}
|
||||
if (canApplyTypedLocalFilterValue.value && state.serverFilterOptions.value.length === 0) {
|
||||
if (canApplyTypedLocalFilterValue.value && serverFilterOptions.value.length === 0) {
|
||||
await applyTypedLocalFilterValue();
|
||||
return;
|
||||
}
|
||||
const columnName = options.getResult().columns[draft.columnIndex];
|
||||
if (!columnName) return;
|
||||
const values = [...draft.values].flatMap((key) => (state.serverFilterValueByKey.value.has(key) ? [state.serverFilterValueByKey.value.get(key)!] : []));
|
||||
const values = [...draft.values].flatMap((key) => (serverFilterValueByKey.value.has(key) ? [serverFilterValueByKey.value.get(key)!] : []));
|
||||
if (values.length === 0) {
|
||||
closeLocalFilter();
|
||||
return;
|
||||
@@ -431,6 +381,10 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
|
||||
if (removedServerFilter && applyServerWhereFilter && options.canUseWhereSearch.value) void options.applyWhereFilter();
|
||||
}
|
||||
|
||||
function resetDistinctValueCache() {
|
||||
distinctValueLoader.reset({ clearCache: true });
|
||||
}
|
||||
|
||||
return {
|
||||
localFilterActive,
|
||||
localFilterCount,
|
||||
@@ -448,6 +402,11 @@ export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOption
|
||||
toggleLocalFilterSort,
|
||||
localFilterTypedValue,
|
||||
canApplyTypedLocalFilterValue,
|
||||
serverFilterLoading,
|
||||
serverFilterError,
|
||||
serverFilterOptions,
|
||||
serverFilterLimited,
|
||||
resetDistinctValueCache,
|
||||
openLocalFilter,
|
||||
closeLocalFilter,
|
||||
toggleLocalFilterValue,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { onUnmounted, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { buildDataGridColumnDistinctValuesSql } from "@/lib/dataGrid/dataGridSql";
|
||||
import { dataGridLocalFilterKey, type DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import { queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import type { ColumnInfo, ConnectionConfig, DatabaseType, QueryResult } from "@/types/database";
|
||||
|
||||
export const DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT = 1000;
|
||||
export const DATA_GRID_DISTINCT_VALUE_MAX_LIMIT = 1000;
|
||||
export const DATA_GRID_DISTINCT_VALUE_DEBOUNCE_MS = 300;
|
||||
const DATA_GRID_DISTINCT_VALUE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
interface DataGridDistinctValueTableMeta {
|
||||
catalog?: string;
|
||||
database?: string;
|
||||
schema?: string;
|
||||
tableName: string;
|
||||
columns: ColumnInfo[];
|
||||
}
|
||||
|
||||
export interface DataGridDistinctValueLoadRequest {
|
||||
columnIndex: number;
|
||||
columnName: string;
|
||||
searchValue?: string;
|
||||
limit?: number;
|
||||
includeCounts?: boolean;
|
||||
}
|
||||
|
||||
export interface UseDataGridDistinctValueLoaderOptions {
|
||||
scopeIdentity: MaybeRefOrGetter<string>;
|
||||
getConnectionId: () => string | undefined;
|
||||
getExecutionDatabase: () => string;
|
||||
getSchema: () => string | undefined;
|
||||
getDatabaseType: () => DatabaseType | undefined;
|
||||
getConnectionConfig: () => Pick<ConnectionConfig, "driver_profile" | "query_timeout_secs" | "query_timeout_inherit"> | undefined;
|
||||
getIdentifierQuote: () => string | undefined;
|
||||
getGlobalQueryTimeoutSecs: () => number | undefined;
|
||||
waitForTableMeta: () => Promise<DataGridDistinctValueTableMeta | null>;
|
||||
formatValue: (value: CellValue, columnIndex: number) => string;
|
||||
keyForValue?: (value: CellValue, columnInfo?: ColumnInfo) => string;
|
||||
}
|
||||
|
||||
type CachedDistinctValues = {
|
||||
expiresAt: number;
|
||||
options: DataGridLocalFilterOption[];
|
||||
limited: boolean;
|
||||
};
|
||||
|
||||
export interface DataGridDistinctValueLoader {
|
||||
loading: Ref<boolean>;
|
||||
error: Ref<string>;
|
||||
options: Ref<DataGridLocalFilterOption[]>;
|
||||
limited: Ref<boolean>;
|
||||
valueByKey: Ref<Map<string, CellValue>>;
|
||||
load: (request: DataGridDistinctValueLoadRequest) => Promise<void>;
|
||||
schedule: (request: DataGridDistinctValueLoadRequest, debounceMs?: number) => void;
|
||||
reset: (options?: { clearCache?: boolean }) => void;
|
||||
}
|
||||
|
||||
export function useDataGridDistinctValueLoader(options: UseDataGridDistinctValueLoaderOptions): DataGridDistinctValueLoader {
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const distinctOptions = ref<DataGridLocalFilterOption[]>([]);
|
||||
const limited = ref(false);
|
||||
const valueByKey = ref<Map<string, CellValue>>(new Map());
|
||||
const cache = new Map<string, CachedDistinctValues>();
|
||||
let requestId = 0;
|
||||
let timer: ReturnType<typeof window.setTimeout> | undefined;
|
||||
|
||||
function normalizedLimit(limit?: number) {
|
||||
return Math.min(DATA_GRID_DISTINCT_VALUE_MAX_LIMIT, Math.max(1, Math.trunc(limit ?? DATA_GRID_DISTINCT_VALUE_DEFAULT_LIMIT)));
|
||||
}
|
||||
|
||||
function cancelPending() {
|
||||
requestId += 1;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
timer = undefined;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function reset(resetOptions: { clearCache?: boolean } = {}) {
|
||||
cancelPending();
|
||||
error.value = "";
|
||||
distinctOptions.value = [];
|
||||
limited.value = false;
|
||||
valueByKey.value = new Map();
|
||||
if (resetOptions.clearCache) cache.clear();
|
||||
}
|
||||
|
||||
function optionFromRow(row: QueryResult["rows"][number], columnIndex: number, columnInfo?: ColumnInfo): DataGridLocalFilterOption {
|
||||
const value = (row[0] ?? null) as CellValue;
|
||||
const countValue = Number(row[1]);
|
||||
return {
|
||||
key: options.keyForValue?.(value, columnInfo) ?? dataGridLocalFilterKey(value),
|
||||
label: options.formatValue(value, columnIndex),
|
||||
count: Number.isFinite(countValue) ? countValue : null,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
function optionsFromResult(result: QueryResult, columnIndex: number, columnInfo: ColumnInfo | undefined, displayLimit: number): DataGridLocalFilterOption[] {
|
||||
const byKey = new Map<string, DataGridLocalFilterOption>();
|
||||
for (const row of result.rows) {
|
||||
const option = optionFromRow(row, columnIndex, columnInfo);
|
||||
const current = byKey.get(option.key);
|
||||
if (current) current.count = (current.count ?? 0) + (option.count ?? 0);
|
||||
else byKey.set(option.key, option);
|
||||
if (byKey.size > displayLimit) break;
|
||||
}
|
||||
return [...byKey.values()].slice(0, displayLimit);
|
||||
}
|
||||
|
||||
function applyValues(nextOptions: DataGridLocalFilterOption[], nextLimited: boolean) {
|
||||
const nextValueByKey = new Map(valueByKey.value);
|
||||
for (const option of nextOptions) nextValueByKey.set(option.key, option.value);
|
||||
valueByKey.value = nextValueByKey;
|
||||
distinctOptions.value = nextOptions;
|
||||
limited.value = nextLimited;
|
||||
}
|
||||
|
||||
function cacheKey(request: DataGridDistinctValueLoadRequest, tableMeta: DataGridDistinctValueTableMeta, displayLimit: number) {
|
||||
return JSON.stringify([
|
||||
toValue(options.scopeIdentity),
|
||||
options.getConnectionId() ?? "",
|
||||
options.getExecutionDatabase(),
|
||||
tableMeta.catalog ?? "",
|
||||
tableMeta.database ?? "",
|
||||
tableMeta.schema ?? "",
|
||||
tableMeta.tableName,
|
||||
request.columnName,
|
||||
request.searchValue?.trim() ?? "",
|
||||
displayLimit,
|
||||
request.includeCounts !== false,
|
||||
]);
|
||||
}
|
||||
|
||||
async function performLoad(request: DataGridDistinctValueLoadRequest, activeRequestId: number) {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
limited.value = false;
|
||||
try {
|
||||
const connectionId = options.getConnectionId();
|
||||
if (!connectionId || !request.columnName) return;
|
||||
const tableMeta = await options.waitForTableMeta();
|
||||
if (!tableMeta || activeRequestId !== requestId) return;
|
||||
const displayLimit = normalizedLimit(request.limit);
|
||||
const key = cacheKey(request, tableMeta, displayLimit);
|
||||
const now = Date.now();
|
||||
for (const [cachedKey, cachedValue] of cache) {
|
||||
if (cachedValue.expiresAt <= now) cache.delete(cachedKey);
|
||||
}
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
applyValues(cached.options, cached.limited);
|
||||
return;
|
||||
}
|
||||
|
||||
const columnInfo = tableMeta.columns.find((column) => column.name === request.columnName);
|
||||
const connection = options.getConnectionConfig();
|
||||
const sql = await buildDataGridColumnDistinctValuesSql({
|
||||
databaseType: options.getDatabaseType(),
|
||||
driverProfile: connection?.driver_profile,
|
||||
identifierQuote: options.getIdentifierQuote(),
|
||||
catalog: tableMeta.catalog,
|
||||
database: tableMeta.database,
|
||||
schema: tableMeta.schema,
|
||||
tableName: tableMeta.tableName,
|
||||
columnName: request.columnName,
|
||||
columnInfo,
|
||||
searchValue: request.searchValue?.trim() || undefined,
|
||||
limit: displayLimit,
|
||||
includeCounts: request.includeCounts !== false,
|
||||
excludeNulls: false,
|
||||
});
|
||||
const result = await api.executeQuery(connectionId, options.getExecutionDatabase(), sql, tableMeta.schema ?? options.getSchema(), undefined, {
|
||||
maxRows: displayLimit,
|
||||
fetchSize: displayLimit,
|
||||
pageSize: displayLimit,
|
||||
timeoutSecs: queryTimeoutSecsForConnection(connection, options.getGlobalQueryTimeoutSecs()),
|
||||
});
|
||||
if (activeRequestId !== requestId) return;
|
||||
const nextOptions = optionsFromResult(result, request.columnIndex, columnInfo, displayLimit);
|
||||
const nextLimited = result.truncated === true || result.rows.length >= displayLimit;
|
||||
cache.set(key, {
|
||||
expiresAt: Date.now() + DATA_GRID_DISTINCT_VALUE_CACHE_TTL_MS,
|
||||
options: nextOptions,
|
||||
limited: nextLimited,
|
||||
});
|
||||
applyValues(nextOptions, nextLimited);
|
||||
} catch (loadError: any) {
|
||||
if (activeRequestId !== requestId) return;
|
||||
distinctOptions.value = [];
|
||||
limited.value = false;
|
||||
error.value = String(loadError?.message || loadError);
|
||||
} finally {
|
||||
if (activeRequestId === requestId) loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function load(request: DataGridDistinctValueLoadRequest) {
|
||||
cancelPending();
|
||||
const activeRequestId = requestId;
|
||||
return performLoad(request, activeRequestId);
|
||||
}
|
||||
|
||||
function schedule(request: DataGridDistinctValueLoadRequest, debounceMs = DATA_GRID_DISTINCT_VALUE_DEBOUNCE_MS) {
|
||||
cancelPending();
|
||||
const activeRequestId = requestId;
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
timer = window.setTimeout(
|
||||
() => {
|
||||
timer = undefined;
|
||||
void performLoad(request, activeRequestId);
|
||||
},
|
||||
Math.max(0, debounceMs),
|
||||
);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => toValue(options.scopeIdentity),
|
||||
() => reset({ clearCache: true }),
|
||||
);
|
||||
onUnmounted(() => reset({ clearCache: true }));
|
||||
|
||||
return { loading, error, options: distinctOptions, limited, valueByKey, load, schedule, reset };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatFilterRawValue, formatFilterRawValues, parseFilterValue, parseFilterValues } from "@/lib/dataGrid/dataGridColumnFilter";
|
||||
import { dataGridNullSuggestionFilterMode, toggleAllDataGridDistinctValueOptions } from "@/lib/dataGrid/dataGridDistinctValueSuggestions";
|
||||
|
||||
describe("data grid filter candidate value serialization", () => {
|
||||
it("keeps simple single values readable and quotes surrounding whitespace", () => {
|
||||
expect(formatFilterRawValue("active")).toBe("active");
|
||||
const raw = formatFilterRawValue(" active ");
|
||||
expect(raw).toBe("' active '");
|
||||
expect(parseFilterValue(raw)).toBe(" active ");
|
||||
expect(parseFilterValue(formatFilterRawValue("'quoted'"))).toBe("'quoted'");
|
||||
});
|
||||
|
||||
it("round-trips commas, newlines, quotes, and the literal string null in lists", () => {
|
||||
const raw = formatFilterRawValues(["north,east", "line 1\nline 2", "O'Brien", "null"]);
|
||||
expect(parseFilterValues(raw)).toEqual(["north,east", "line 1\nline 2", "O'Brien", "null"]);
|
||||
});
|
||||
|
||||
it("maps a selected NULL candidate to a null-aware single-value operator", () => {
|
||||
expect(dataGridNullSuggestionFilterMode("equals")).toBe("is-null");
|
||||
expect(dataGridNullSuggestionFilterMode("like")).toBe("is-null");
|
||||
expect(dataGridNullSuggestionFilterMode("not-equals")).toBe("is-not-null");
|
||||
expect(dataGridNullSuggestionFilterMode("not-like")).toBe("is-not-null");
|
||||
});
|
||||
|
||||
it("toggles all currently displayed candidates without removing hidden selections", () => {
|
||||
const options = [
|
||||
{ key: "str:a", label: "a", value: "a", count: 2 },
|
||||
{ key: "str:b", label: "b", value: "b", count: 1 },
|
||||
];
|
||||
const hiddenSelection = new Map<string, string>([["str:manual", "manual"]]);
|
||||
|
||||
const selected = toggleAllDataGridDistinctValueOptions(hiddenSelection, options);
|
||||
expect([...selected.entries()]).toEqual([
|
||||
["str:manual", "manual"],
|
||||
["str:a", "a"],
|
||||
["str:b", "b"],
|
||||
]);
|
||||
|
||||
const cleared = toggleAllDataGridDistinctValueOptions(selected, options);
|
||||
expect([...cleared.entries()]).toEqual([["str:manual", "manual"]]);
|
||||
});
|
||||
});
|
||||
@@ -230,6 +230,21 @@ export function parseFilterValues(rawValue: string, columnInfo?: Pick<DataGridCo
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFilterRawValue(value: GridCellValue, quoteStrings = false): string {
|
||||
if (typeof value === "string") {
|
||||
const wrappedInMatchingQuotes = value.length >= 2 && ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"')));
|
||||
if (!quoteStrings && value.trim() === value && !wrappedInMatchingQuotes) return value;
|
||||
return `'${value.replaceAll("'", "''")}'`;
|
||||
}
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "object") return `'${JSON.stringify(value).replaceAll("'", "''")}'`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatFilterRawValues(values: readonly GridCellValue[]): string {
|
||||
return values.map((value) => formatFilterRawValue(value, true)).join(", ");
|
||||
}
|
||||
|
||||
type ParsedFilterValue = {
|
||||
value: string;
|
||||
quoted: boolean;
|
||||
@@ -295,7 +310,8 @@ function unwrapMatchingQuotes(text: string): string {
|
||||
const first = text[0];
|
||||
const last = text[text.length - 1];
|
||||
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
||||
return text.slice(1, -1);
|
||||
const quote = first as "'" | '"';
|
||||
return text.slice(1, -1).replaceAll(`${quote}${quote}`, quote);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DataGridLocalFilterOption } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import { dataGridLocalFilterKey } from "@/lib/dataGrid/dataGridLocalColumnFilterState";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import type { DataGridContextFilterMode } from "@/lib/dataGrid/dataGridSql";
|
||||
import type { ColumnInfo } from "@/types/database";
|
||||
|
||||
export type DataGridDistinctValueSuggestionTarget = "value" | "end";
|
||||
|
||||
export interface DataGridDistinctValueSuggestionState {
|
||||
ruleId?: string;
|
||||
target?: DataGridDistinctValueSuggestionTarget;
|
||||
search: string;
|
||||
options: DataGridLocalFilterOption[];
|
||||
loading: boolean;
|
||||
error: string;
|
||||
limited: boolean;
|
||||
limit: number;
|
||||
selectedKeys: Set<string>;
|
||||
}
|
||||
|
||||
export function dataGridDistinctValueKey(value: CellValue, columnInfo?: Pick<ColumnInfo, "data_type">): string {
|
||||
const typeParts = (columnInfo?.data_type ?? "").toLowerCase().split(/[^a-z0-9]+/);
|
||||
if (typeParts.some((part) => ["int", "integer", "bigint", "smallint", "tinyint", "mediumint", "serial", "number", "numeric", "decimal", "float", "double", "real", "money"].includes(part))) return `num:${String(value)}`;
|
||||
return dataGridLocalFilterKey(value);
|
||||
}
|
||||
|
||||
export function dataGridNullSuggestionFilterMode(mode: DataGridContextFilterMode): "is-null" | "is-not-null" {
|
||||
return mode === "not-equals" || mode === "not-like" || mode === "not-between" ? "is-not-null" : "is-null";
|
||||
}
|
||||
|
||||
export function toggleAllDataGridDistinctValueOptions(selectedValues: ReadonlyMap<string, CellValue>, options: readonly DataGridLocalFilterOption[]): Map<string, CellValue> {
|
||||
const next = new Map(selectedValues);
|
||||
if (!options.length) return next;
|
||||
|
||||
const allSelected = options.every((option) => next.has(option.key));
|
||||
for (const option of options) {
|
||||
if (allSelected) next.delete(option.key);
|
||||
else next.set(option.key, option.value);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -105,6 +105,7 @@ export interface DataGridColumnDistinctValuesSqlOptions {
|
||||
searchValue?: string;
|
||||
limit?: number;
|
||||
includeCounts?: boolean;
|
||||
excludeNulls?: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridCountSqlOptions {
|
||||
|
||||
@@ -290,6 +290,8 @@ pub struct DataGridColumnDistinctValuesSqlOptions {
|
||||
pub limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub include_counts: bool,
|
||||
#[serde(default)]
|
||||
pub exclude_nulls: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -1035,6 +1037,9 @@ pub fn build_data_grid_column_distinct_values_sql(options: DataGridColumnDistinc
|
||||
if !predicate.is_empty() {
|
||||
predicates.push(format!("({predicate})"));
|
||||
}
|
||||
if options.exclude_nulls {
|
||||
predicates.push(format!("{column} IS NOT NULL"));
|
||||
}
|
||||
if let Some(search_predicate) = data_grid_column_distinct_values_search_predicate(&options) {
|
||||
predicates.push(search_predicate);
|
||||
}
|
||||
@@ -1195,6 +1200,9 @@ fn build_neo4j_data_grid_column_distinct_values_sql(options: &DataGridColumnDist
|
||||
if !predicate.is_empty() {
|
||||
predicates.push(predicate);
|
||||
}
|
||||
if options.exclude_nulls {
|
||||
predicates.push(format!("{column} IS NOT NULL"));
|
||||
}
|
||||
if let Some(search) = options.search_value.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
predicates.push(format!(
|
||||
"toString({column}) CONTAINS {}",
|
||||
@@ -5990,8 +5998,9 @@ mod tests {
|
||||
search_value: Some("act".to_string()),
|
||||
limit: None,
|
||||
include_counts: true,
|
||||
exclude_nulls: true,
|
||||
}),
|
||||
"SELECT \"status\" AS dbx_value, COUNT(*) AS dbx_count FROM \"public\".\"users\" WHERE (deleted_at IS NULL) AND \"status\" LIKE '%act%' GROUP BY \"status\" ORDER BY dbx_count DESC, dbx_value LIMIT 1000"
|
||||
"SELECT \"status\" AS dbx_value, COUNT(*) AS dbx_count FROM \"public\".\"users\" WHERE (deleted_at IS NULL) AND \"status\" IS NOT NULL AND \"status\" LIKE '%act%' GROUP BY \"status\" ORDER BY dbx_count DESC, dbx_value LIMIT 1000"
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
|
||||
@@ -6008,8 +6017,9 @@ mod tests {
|
||||
search_value: None,
|
||||
limit: Some(25),
|
||||
include_counts: false,
|
||||
exclude_nulls: true,
|
||||
}),
|
||||
"SELECT TOP (25) [status] AS dbx_value FROM [users] GROUP BY [status] ORDER BY dbx_value"
|
||||
"SELECT TOP (25) [status] AS dbx_value FROM [users] WHERE [status] IS NOT NULL GROUP BY [status] ORDER BY dbx_value"
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
|
||||
@@ -6026,6 +6036,7 @@ mod tests {
|
||||
search_value: Some("42".to_string()),
|
||||
limit: Some(25),
|
||||
include_counts: true,
|
||||
exclude_nulls: false,
|
||||
}),
|
||||
"SELECT TOP (25) [id] AS dbx_value, COUNT(*) AS dbx_count FROM [users] WHERE [id] = 42 GROUP BY [id] ORDER BY dbx_count DESC, dbx_value"
|
||||
);
|
||||
@@ -6044,6 +6055,7 @@ mod tests {
|
||||
search_value: None,
|
||||
limit: Some(25),
|
||||
include_counts: true,
|
||||
exclude_nulls: false,
|
||||
}),
|
||||
"SELECT [status] AS dbx_value, COUNT(*) AS dbx_count FROM [users] GROUP BY [status] ORDER BY dbx_count DESC, dbx_value"
|
||||
);
|
||||
@@ -6062,6 +6074,7 @@ mod tests {
|
||||
search_value: None,
|
||||
limit: Some(10),
|
||||
include_counts: true,
|
||||
exclude_nulls: false,
|
||||
}),
|
||||
"SELECT * FROM (SELECT \"KIND\" AS dbx_value, COUNT(*) AS dbx_count FROM \"APP\".\"EVENTS\" GROUP BY \"KIND\" ORDER BY dbx_count DESC, dbx_value) WHERE ROWNUM <= 10"
|
||||
);
|
||||
@@ -6080,6 +6093,7 @@ mod tests {
|
||||
search_value: None,
|
||||
limit: Some(25),
|
||||
include_counts: false,
|
||||
exclude_nulls: false,
|
||||
}),
|
||||
"SELECT \"STATUS\" AS dbx_value FROM \"USERS\" WHERE (DELETED_AT IS NULL) GROUP BY \"STATUS\" ORDER BY dbx_value ROWS 25"
|
||||
);
|
||||
@@ -6099,6 +6113,7 @@ mod tests {
|
||||
search_value: None,
|
||||
limit: Some(10),
|
||||
include_counts: false,
|
||||
exclude_nulls: false,
|
||||
}),
|
||||
"SELECT `status` AS dbx_value FROM `iceberg_catalog`.`sales`.`orders` GROUP BY `status` ORDER BY dbx_value LIMIT 10"
|
||||
);
|
||||
@@ -6117,6 +6132,7 @@ mod tests {
|
||||
search_value: None,
|
||||
limit: Some(10),
|
||||
include_counts: true,
|
||||
exclude_nulls: false,
|
||||
}),
|
||||
"SELECT `status` AS dbx_value, COUNT(*) AS dbx_count FROM `hive_catalog`.`orders` GROUP BY `status` ORDER BY dbx_count DESC, dbx_value LIMIT 10"
|
||||
);
|
||||
@@ -6136,9 +6152,29 @@ mod tests {
|
||||
search_value: None,
|
||||
limit: Some(10),
|
||||
include_counts: false,
|
||||
exclude_nulls: false,
|
||||
}),
|
||||
"SELECT `status` AS dbx_value FROM `orders` GROUP BY `status` ORDER BY dbx_value LIMIT 10"
|
||||
);
|
||||
assert_eq!(
|
||||
build_data_grid_column_distinct_values_sql(DataGridColumnDistinctValuesSqlOptions {
|
||||
database_type: Some(DatabaseType::Neo4j),
|
||||
driver_profile: None,
|
||||
identifier_quote: None,
|
||||
catalog: None,
|
||||
database: None,
|
||||
schema: None,
|
||||
table_name: "User".to_string(),
|
||||
column_name: "status".to_string(),
|
||||
column_info: Some(column("status", "string", true, None)),
|
||||
where_input: Some("n.active = true".to_string()),
|
||||
search_value: None,
|
||||
limit: Some(10),
|
||||
include_counts: true,
|
||||
exclude_nulls: true,
|
||||
}),
|
||||
"MATCH (n:`User`) WHERE n.active = true AND n.`status` IS NOT NULL RETURN n.`status` AS dbx_value, count(*) AS dbx_count ORDER BY dbx_count DESC, dbx_value LIMIT 10"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user