refactor(grid): extract column filter workflow

This commit is contained in:
t8y2 committed 2026-09-08 23:46:42 +08:00
1 parent ecc19fdd30
commit 16eb52c81f
2 files changed
+507 -422

No files matched your search

@@ -0,0 +1,437 @@
import { computed, onUnmounted, watch, type ComputedRef, type Ref } from "vue";
import * as api from "@/lib/backend/api";
import { buildColumnValueFilterCondition, buildColumnValuesFilterCondition, parseFilterValue, removeColumnValueFilterCondition, replaceColumnValueFilterCondition, appendColumnValueFilterCondition } from "@/lib/dataGrid/dataGridColumnFilter";
import { buildDataGridColumnDistinctValuesSql } from "@/lib/dataGrid/dataGridSql";
import { buildDataGridLocalFilterOptions, 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";
export type DataGridLocalFilterMode = "local" | "server";
export type DataGridLocalFilterDraft = {
columnIndex: number;
values: Set<string>;
mode: DataGridLocalFilterMode;
touched: boolean;
};
export interface DataGridColumnFilterState {
localColumnFilters: Ref<Record<number, Set<string>>>;
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>>;
}
interface TableMetadata {
catalog?: string;
database?: string;
schema?: string;
tableName: string;
columns: ColumnInfo[];
}
interface ConnectionConfig {
driver_profile?: string;
}
export interface UseDataGridColumnFiltersOptions {
state: DataGridColumnFilterState;
getResult: () => QueryResult;
getTableMeta: () => TableMetadata | undefined;
getConnectionId: () => string | undefined;
getSchema: () => string | undefined;
getExecutionDatabase: () => string;
resolvedDatabaseType: ComputedRef<DatabaseType | undefined>;
canUseWhereSearch: ComputedRef<boolean>;
canUseServerColumnFilter: ComputedRef<boolean>;
structuredFilterCount: ComputedRef<number>;
hasStructuredFilters: ComputedRef<boolean>;
whereFilterInput: Ref<string>;
getConnectionConfig: () => ConnectionConfig | undefined;
getIdentifierQuote: () => string | undefined;
getNewRows: () => readonly (readonly CellValue[])[];
getRowData: (row: CellValue[], sourceIndex: number) => readonly CellValue[];
formatValue: (value: CellValue, columnIndex: number) => string;
waitForTableMeta: () => Promise<TableMetadata | null>;
applyWhereFilter: () => Promise<void>;
resetGridVerticalScroll: () => void;
onClose?: () => void;
emitLocalFiltersChange: (filters: Record<string, string[]>) => void;
}
export const DATA_GRID_SERVER_COLUMN_FILTER_LIMIT = 1000;
const SERVER_COLUMN_FILTER_DEBOUNCE_MS = 300;
export function useDataGridColumnFilters(options: UseDataGridColumnFiltersOptions) {
const { state } = options;
let serverFilterRequestId = 0;
let serverFilterSearchTimer: ReturnType<typeof window.setTimeout> | undefined;
watch(
state.localColumnFilters,
(filters) => {
options.emitLocalFiltersChange(Object.fromEntries(Object.entries(filters).flatMap(([columnIndex, values]) => (values.size > 0 ? [[columnIndex, [...values]]] : []))));
},
{ deep: true },
);
function localFilterLabel(value: CellValue, columnIndex: number): string {
return dataGridLocalFilterLabel(value, columnIndex, options.formatValue);
}
function buildLocalFilterOptions(columnIndex: number): DataGridLocalFilterOption[] {
const result = options.getResult();
return buildDataGridLocalFilterOptions({
rows: result.rows,
newRows: options.getNewRows(),
columnIndex,
getRowData: options.getRowData,
formatValue: options.formatValue,
});
}
const localFilterActive = (columnIndex: number) => !!state.localColumnFilters.value[columnIndex]?.size || !!state.serverColumnFilters.value[columnIndex];
const localFilterCount = computed(() => Object.values(state.localColumnFilters.value).filter((values) => values.size).length);
const serverColumnFilterCount = computed(() => Object.keys(state.serverColumnFilters.value).length);
const hasLocalColumnFilters = computed(() => localFilterCount.value > 0);
const hasServerColumnFilters = computed(() => serverColumnFilterCount.value > 0);
const filterButtonCount = computed(() => options.structuredFilterCount.value + localFilterCount.value + serverColumnFilterCount.value);
const filterButtonActive = computed(() => options.hasStructuredFilters.value || hasLocalColumnFilters.value || hasServerColumnFilters.value);
const localFilterSummaries = computed(() =>
[
...Object.entries(state.localColumnFilters.value)
.filter(([, selected]) => selected.size > 0)
.map(([columnIndexText, selected]) => {
const columnIndex = Number(columnIndexText);
const labelByKey = new Map(buildLocalFilterOptions(columnIndex).map((option) => [option.key, option.label]));
return { columnIndex, values: [...selected].map((key) => labelByKey.get(key) ?? key) };
}),
...Object.entries(state.serverColumnFilters.value).map(([columnIndexText, filter]) => ({ columnIndex: Number(columnIndexText), values: filter.labels })),
].map(({ columnIndex, values }) => ({
columnIndex,
columnName: options.getResult().columns[columnIndex] ?? `#${columnIndex + 1}`,
values: values.slice(0, 3),
hiddenValueCount: Math.max(0, values.length - 3),
})),
);
const localFilteredRows = computed(() => {
const rows = options.getResult().rows;
if (!hasLocalColumnFilters.value) return rows.map((_, index) => index);
return rows.flatMap((row, index) => (rowMatchesDataGridLocalColumnFilters(options.getRowData(row, index), state.localColumnFilters.value) ? [index] : []));
});
const rowMatchesLocalColumnFilters = (data: CellValue[]) => rowMatchesDataGridLocalColumnFilters(data, state.localColumnFilters.value);
const localFilterAllOptions = computed(() => {
if (state.localFilterDraft.value?.mode === "server") return state.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;
const query = state.localFilterSearch.value.trim().toLowerCase();
return localFilterAllOptions.value.filter((option) => !query || option.label.toLowerCase().includes(query)).slice(0, 500);
});
const localFilterTypedValue = computed(() => state.localFilterSearch.value.trim());
const localFilterDraftIsAllSelected = computed(() => {
const draft = state.localFilterDraft.value;
const allKeys = localFilterAllOptions.value.map((option) => option.key);
return !!draft && allKeys.length > 0 && allKeys.every((key) => draft.values.has(key));
});
const localFilterAllVisibleSelected = computed(() => {
const draft = state.localFilterDraft.value;
return !!draft && localFilterOptions.value.length > 0 && localFilterOptions.value.every((option) => draft.values.has(option.key));
});
const canApplyTypedLocalFilterValue = computed(() => {
const typed = localFilterTypedValue.value;
if (!state.localFilterDraft.value || !typed || !options.canUseWhereSearch.value) return false;
const normalized = typed.toLowerCase();
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") {
state.localFilterSearch.value = "";
const mode = requestedMode === "server" && options.canUseServerColumnFilter.value ? "server" : "local";
const allKeys = mode === "server" ? [] : buildLocalFilterOptions(columnIndex).map((option) => option.key);
state.localFilterDraft.value = {
columnIndex,
values: new Set(mode === "server" ? allKeys : (state.localColumnFilters.value[columnIndex] ?? allKeys)),
mode,
touched: false,
};
state.localFilterOpenColumn.value = columnIndex;
resetServerFilterState();
if (mode === "server") void loadServerFilterValues(columnIndex, "");
}
function closeLocalFilter() {
options.onClose?.();
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()];
}
function syncServerFilterDraft(columnIndex: number, filterOptions: DataGridLocalFilterOption[]) {
const draft = state.localFilterDraft.value;
if (!draft || draft.mode !== "server" || draft.columnIndex !== columnIndex || draft.touched) return;
const activeFilter = state.serverColumnFilters.value[columnIndex];
state.localFilterDraft.value = { ...draft, values: new Set(activeFilter?.keys ?? filterOptions.map((option) => option.key)) };
}
async function loadServerFilterValues(columnIndex: number, searchValue: string) {
const connectionId = options.getConnectionId();
if (!options.canUseServerColumnFilter.value || !connectionId) 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;
}
}
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);
});
onUnmounted(resetServerFilterState);
function toggleLocalFilterValue(key: string) {
const draft = state.localFilterDraft.value;
if (!draft) return;
const values = new Set(draft.values);
if (values.has(key)) values.delete(key);
else values.add(key);
state.localFilterDraft.value = { ...draft, values, touched: true };
}
function toggleAllLocalFilterOptions() {
const draft = state.localFilterDraft.value;
if (!draft) return;
const visibleKeys = localFilterOptions.value.map((option) => option.key);
const values = new Set(draft.values);
if (localFilterAllVisibleSelected.value) visibleKeys.forEach((key) => values.delete(key));
else visibleKeys.forEach((key) => values.add(key));
state.localFilterDraft.value = { ...draft, values, touched: true };
}
async function applyLocalFilter() {
const draft = state.localFilterDraft.value;
if (!draft) return;
if (draft.mode === "server") {
await applyServerColumnFilter(draft);
return;
}
if (canApplyTypedLocalFilterValue.value && localFilterDraftIsAllSelected.value && localFilterOptions.value.length === 0) {
await applyTypedLocalFilterValue();
return;
}
const allKeys = new Set(localFilterAllOptions.value.map((option) => option.key));
const selected = state.localFilterSearch.value.trim() ? new Set([...draft.values].filter((key) => localFilterOptions.value.some((option) => option.key === key))) : draft.values;
const next = { ...state.localColumnFilters.value };
if (selected.size === 0 || selected.size === allKeys.size) delete next[draft.columnIndex];
else next[draft.columnIndex] = new Set(selected);
state.localColumnFilters.value = next;
closeLocalFilter();
options.resetGridVerticalScroll();
}
async function applyServerColumnFilter(draft: DataGridLocalFilterDraft) {
if (!draft.touched && !state.localFilterSearch.value.trim()) {
closeLocalFilter();
return;
}
if (canApplyTypedLocalFilterValue.value && state.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)!] : []));
if (values.length === 0) {
closeLocalFilter();
return;
}
const condition = await buildColumnValuesFilterCondition({
databaseType: options.resolvedDatabaseType.value,
identifierQuote: options.getIdentifierQuote(),
columnName,
columnInfo: options.getTableMeta()?.columns.find((column) => column.name === columnName),
values,
});
if (!condition) return;
const next = { ...state.localColumnFilters.value };
delete next[draft.columnIndex];
state.localColumnFilters.value = next;
const previousCondition = state.serverColumnFilters.value[draft.columnIndex]?.condition;
options.whereFilterInput.value = replaceColumnValueFilterCondition(options.whereFilterInput.value, previousCondition, condition);
state.serverColumnFilters.value = {
...state.serverColumnFilters.value,
[draft.columnIndex]: { condition, keys: [...draft.values], labels: values.map((value) => localFilterLabel(value, draft.columnIndex)) },
};
closeLocalFilter();
await options.applyWhereFilter();
}
async function applyTypedLocalFilterValue() {
const draft = state.localFilterDraft.value;
if (!draft) return;
const columnName = options.getResult().columns[draft.columnIndex];
if (!columnName) return;
const columnInfo = options.getTableMeta()?.columns.find((column) => column.name === columnName);
const condition = await buildColumnValueFilterCondition({
databaseType: options.resolvedDatabaseType.value,
identifierQuote: options.getIdentifierQuote(),
columnName,
columnInfo,
rawValue: localFilterTypedValue.value,
});
if (!condition) return;
const next = { ...state.localColumnFilters.value };
delete next[draft.columnIndex];
state.localColumnFilters.value = next;
if (draft.mode === "server") {
const previousCondition = state.serverColumnFilters.value[draft.columnIndex]?.condition;
const rawValue = localFilterTypedValue.value.trim();
const value = (/^null$/i.test(rawValue) ? null : parseFilterValue(rawValue, columnInfo, options.resolvedDatabaseType.value)) as CellValue;
options.whereFilterInput.value = replaceColumnValueFilterCondition(options.whereFilterInput.value, previousCondition, condition);
state.serverColumnFilters.value = {
...state.serverColumnFilters.value,
[draft.columnIndex]: { condition, keys: [dataGridLocalFilterKey(value)], labels: [localFilterLabel(value, draft.columnIndex)] },
};
} else {
options.whereFilterInput.value = appendColumnValueFilterCondition(options.whereFilterInput.value, condition);
}
closeLocalFilter();
await options.applyWhereFilter();
}
function clearLocalFilter(columnIndex?: number, applyServerWhereFilter = true) {
let removedServerFilter = false;
if (columnIndex === undefined) {
state.localColumnFilters.value = {};
let nextWhereInput = options.whereFilterInput.value;
for (const filter of Object.values(state.serverColumnFilters.value)) nextWhereInput = removeColumnValueFilterCondition(nextWhereInput, filter.condition);
removedServerFilter = Object.keys(state.serverColumnFilters.value).length > 0;
state.serverColumnFilters.value = {};
options.whereFilterInput.value = nextWhereInput;
} else {
const next = { ...state.localColumnFilters.value };
delete next[columnIndex];
state.localColumnFilters.value = next;
const serverFilter = state.serverColumnFilters.value[columnIndex];
if (serverFilter) {
removedServerFilter = true;
const nextServerFilters = { ...state.serverColumnFilters.value };
delete nextServerFilters[columnIndex];
state.serverColumnFilters.value = nextServerFilters;
options.whereFilterInput.value = removeColumnValueFilterCondition(options.whereFilterInput.value, serverFilter.condition);
}
}
closeLocalFilter();
options.resetGridVerticalScroll();
if (removedServerFilter && applyServerWhereFilter && options.canUseWhereSearch.value) void options.applyWhereFilter();
}
return {
localFilterActive,
localFilterCount,
serverColumnFilterCount,
hasLocalColumnFilters,
hasServerColumnFilters,
filterButtonCount,
filterButtonActive,
localFilterSummaries,
localFilteredRows,
rowMatchesLocalColumnFilters,
localFilterAllOptions,
localFilterOptions,
localFilterTypedValue,
canApplyTypedLocalFilterValue,
openLocalFilter,
closeLocalFilter,
toggleLocalFilterValue,
toggleAllLocalFilterOptions,
applyLocalFilter,
applyTypedLocalFilterValue,
clearLocalFilter,
};
}