diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 6ca2b38c9..31a383a1b 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -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; @@ -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([]); -const serverFilterLimited = ref(false); -const serverFilterValueByKey = ref>(new Map()); const serverColumnFilters = ref>({}); 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(); +const filterValueSuggestionTarget = ref(); +const filterValueSuggestionSearch = ref(""); +const filterValueSuggestionDraftValues = ref(new Map()); +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(() => ({ + 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) { + 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" /> @@ -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" /> (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" />
diff --git a/apps/desktop/src/components/grid/DataGridDistinctValuePopover.vue b/apps/desktop/src/components/grid/DataGridDistinctValuePopover.vue new file mode 100644 index 000000000..d630fd5bd --- /dev/null +++ b/apps/desktop/src/components/grid/DataGridDistinctValuePopover.vue @@ -0,0 +1,108 @@ + + + diff --git a/apps/desktop/src/components/grid/DataGridFilterBuilder.vue b/apps/desktop/src/components/grid/DataGridFilterBuilder.vue index f4251118c..0f20467ec 100644 --- a/apps/desktop/src/components/grid/DataGridFilterBuilder.vue +++ b/apps/desktop/src/components/grid/DataGridFilterBuilder.vue @@ -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]; "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(); const filterRuleElements = new Map(); @@ -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}`)" /> + — + + + + - - {{ t("grid.filterBuilderNoValue") }}
+ + +
+
+