diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 22e1a7d..8a9a1b7 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -2,7 +2,12 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; -import DataGrid, { formatCellDisplayText, resolveContextMenuFieldName } from './DataGrid'; +import DataGrid, { + formatCellDisplayText, + resolveContextMenuFieldName, + resolveDefaultGridFilterOperator, + resolveNextGridFilterOperatorForColumnChange, +} from './DataGrid'; vi.mock('../store', () => ({ useStore: (selector: (state: any) => any) => selector({ @@ -99,6 +104,38 @@ describe('DataGrid layout', () => { expect(resolveContextMenuFieldName('', 'fallback_name')).toBe('fallback_name'); }); + it('uses contains as the default filter operator for string-like columns', () => { + expect(resolveDefaultGridFilterOperator('varchar(255)')).toBe('CONTAINS'); + expect(resolveDefaultGridFilterOperator('character varying(64)')).toBe('CONTAINS'); + expect(resolveDefaultGridFilterOperator('nvarchar(max)')).toBe('CONTAINS'); + expect(resolveDefaultGridFilterOperator('Nullable(LowCardinality(String))')).toBe('CONTAINS'); + expect(resolveDefaultGridFilterOperator('text')).toBe('CONTAINS'); + + expect(resolveDefaultGridFilterOperator('int')).toBe('='); + expect(resolveDefaultGridFilterOperator('decimal(10,2)')).toBe('='); + expect(resolveDefaultGridFilterOperator('datetime')).toBe('='); + }); + + it('updates only untouched default filter operators when the column changes', () => { + expect(resolveNextGridFilterOperatorForColumnChange({ + currentOperator: '=', + previousColumnType: 'int', + nextColumnType: 'varchar(64)', + })).toBe('CONTAINS'); + + expect(resolveNextGridFilterOperatorForColumnChange({ + currentOperator: 'CONTAINS', + previousColumnType: 'varchar(64)', + nextColumnType: 'bigint', + })).toBe('='); + + expect(resolveNextGridFilterOperatorForColumnChange({ + currentOperator: 'STARTS_WITH', + previousColumnType: 'varchar(64)', + nextColumnType: 'bigint', + })).toBe('STARTS_WITH'); + }); + it('renders a DDL action for table data pages only', () => { const tableMarkup = renderToStaticMarkup( { + let normalized = String(columnType ?? '').trim().toLowerCase().replace(/\s+/g, ' '); + for (let i = 0; i < 4; i += 1) { + const wrapped = normalized.match(/^(?:nullable|lowcardinality)\((.+)\)$/); + if (!wrapped) break; + normalized = wrapped[1].trim().replace(/\s+/g, ' '); + } + return normalized; +}; + +export const isStringLikeGridFilterColumnType = (columnType: unknown): boolean => { + const normalized = normalizeGridFilterColumnType(columnType); + if (!normalized) return false; + const baseType = normalized.replace(/\(.*/, '').trim(); + return STRING_LIKE_GRID_FILTER_TYPES.has(baseType); +}; + +export const resolveDefaultGridFilterOperator = (columnType: unknown): string => ( + isStringLikeGridFilterColumnType(columnType) ? CONTAINS_GRID_FILTER_OPERATOR : EXACT_GRID_FILTER_OPERATOR +); + +export const resolveNextGridFilterOperatorForColumnChange = ({ + currentOperator, + previousColumnType, + nextColumnType, +}: { + currentOperator: unknown; + previousColumnType: unknown; + nextColumnType: unknown; +}): string => { + const current = String(currentOperator || '').trim(); + if (!current) return resolveDefaultGridFilterOperator(nextColumnType); + const previousDefault = resolveDefaultGridFilterOperator(previousColumnType); + return current === previousDefault ? resolveDefaultGridFilterOperator(nextColumnType) : current; +}; + type NormalizeCommitCellValue = (columnName: string, value: any, mode: 'insert' | 'update') => any; type DataGridCommitChangeSet = { @@ -1755,6 +1817,12 @@ const DataGrid: React.FC = ({ return next; }, [columnMetaMapByLowerName]); + const getColumnFilterType = useCallback((columnName: string): string => { + const normalizedName = String(columnName || '').trim(); + if (!normalizedName) return ''; + return (columnMetaMap[normalizedName] || columnMetaMapByLowerName[normalizedName.toLowerCase()])?.type || ''; + }, [columnMetaMap, columnMetaMapByLowerName]); + const allTableColumnNames = useMemo(() => { const metaColumns = Object.keys(columnMetaMap); if (metaColumns.length > 0) { @@ -2514,7 +2582,7 @@ const DataGrid: React.FC = ({ return conditions.map((cond, index) => { const fallbackId = index + 1; const nextId = Number.isFinite(Number(cond?.id)) ? Number(cond?.id) : fallbackId; - const op = String(cond?.op || '='); + const op = String(cond?.op || EXACT_GRID_FILTER_OPERATOR); const rawColumn = String(cond?.column || ''); return { id: nextId, @@ -2534,9 +2602,11 @@ const DataGrid: React.FC = ({ const [quickWhereDraft, setQuickWhereDraft] = useState(() => normalizeQuickWhereCondition(quickWhereCondition)); const [quickWhereSuggestionsOpen, setQuickWhereSuggestionsOpen] = useState(false); const filterPanelRef = useRef(null); + const autoDefaultFilterIdsRef = useRef>(new Set()); useEffect(() => { const nextConditions = normalizeGridFilterConditions(appliedFilterConditions); + autoDefaultFilterIdsRef.current.clear(); setFilterConditions(nextConditions); const maxId = nextConditions.reduce((max, cond) => (cond.id > max ? cond.id : max), 0); setNextFilterId(Math.max(1, maxId + 1)); @@ -2546,6 +2616,23 @@ const DataGrid: React.FC = ({ setQuickWhereDraft(normalizeQuickWhereCondition(quickWhereCondition)); }, [quickWhereCondition]); + useEffect(() => { + if (Object.keys(columnMetaMap).length === 0) return; + setFilterConditions(prev => { + let changed = false; + const nextConditions = prev.map((cond) => { + if (!autoDefaultFilterIdsRef.current.has(cond.id)) { + return cond; + } + const nextOp = resolveDefaultGridFilterOperator(getColumnFilterType(cond.column)); + if (nextOp === cond.op) return cond; + changed = true; + return { ...cond, op: nextOp }; + }); + return changed ? nextConditions : prev; + }); + }, [columnMetaMap, getColumnFilterType]); + const quickWhereSuggestionOptions = useMemo(() => { const columnSuggestionSource = allTableColumnNames.length > 0 ? allTableColumnNames : displayColumnNames; return resolveWhereConditionSuggestions({ @@ -5007,14 +5094,17 @@ const DataGrid: React.FC = ({ const isListOp = useCallback((op: string) => op === 'IN' || op === 'NOT_IN', []); const addFilter = () => { + const column = displayColumnNames[0] || ''; + const id = nextFilterId; + autoDefaultFilterIdsRef.current.add(id); setFilterConditions([ ...filterConditions, { - id: nextFilterId, + id, enabled: true, logic: 'AND', - column: displayColumnNames[0] || '', - op: '=', + column, + op: resolveDefaultGridFilterOperator(getColumnFilterType(column)), value: '', value2: '', } @@ -5025,7 +5115,21 @@ const DataGrid: React.FC = ({ setFilterConditions(prev => prev.map(c => { if (c.id !== id) return c; const next: GridFilterCondition = { ...c, [field]: val } as GridFilterCondition; + if (field === 'column') { + next.op = resolveNextGridFilterOperatorForColumnChange({ + currentOperator: c.op, + previousColumnType: getColumnFilterType(c.column), + nextColumnType: getColumnFilterType(String(val)), + }); + if (isNoValueOp(next.op)) { + next.value = ''; + next.value2 = ''; + } else if (!isBetweenOp(next.op)) { + next.value2 = ''; + } + } if (field === 'op') { + autoDefaultFilterIdsRef.current.delete(id); const nextOp = String(val); if (isNoValueOp(nextOp)) { next.value = ''; @@ -5040,6 +5144,7 @@ const DataGrid: React.FC = ({ })); }; const removeFilter = (id: number) => { + autoDefaultFilterIdsRef.current.delete(id); setFilterConditions(prev => prev.filter(c => c.id !== id)); }; const applyQuickWhereCondition = useCallback((condition: string = quickWhereDraft): boolean => {