From 75633d7da3151e75cd636c592ba833c6802cd792 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 2 Jun 2026 16:37:21 +0800 Subject: [PATCH 1/6] Make database find bar local-only --- .../database/mongodb/CollectionDetailView.tsx | 2 - .../CollectionView/CollectionViewProvider.tsx | 26 +- .../database/mongodb/CollectionView/types.ts | 2 - .../database/shared/FindBar.Provider.tsx | 18 +- .../sql/TableView/TableViewProvider.tsx | 14 - .../database/sql/TableView/types.ts | 3 - .../database/sql/TableView/useDataQuery.ts | 26 +- dataflow/src/utils/search-parser.ts | 407 ------------------ 8 files changed, 4 insertions(+), 494 deletions(-) delete mode 100644 dataflow/src/utils/search-parser.ts diff --git a/dataflow/src/components/database/mongodb/CollectionDetailView.tsx b/dataflow/src/components/database/mongodb/CollectionDetailView.tsx index b4c187b76..db6ade543 100644 --- a/dataflow/src/components/database/mongodb/CollectionDetailView.tsx +++ b/dataflow/src/components/database/mongodb/CollectionDetailView.tsx @@ -89,8 +89,6 @@ function CollectionDetailViewContent({ databaseName, collectionName, connectionI
{ - runWithDiscardGuard(() => { - setSearchTerm(term) - setCurrentPage(1) - }) - }, [runWithDiscardGuard]) - // ---- Main data fetch ---- useEffect(() => { const fetchData = async () => { @@ -178,19 +169,6 @@ export function CollectionViewProvider({ connectionId, databaseName, collectionN } } - // Add search term as regex on 'document' column if present - if (searchTerm.trim()) { - filterConditions.push({ - Type: WhereConditionType.Atomic, - Atomic: { - Key: 'document', - Operator: 'regex', - Value: searchTerm.trim(), - ColumnType: 'string', - }, - }) - } - let where: WhereCondition | undefined if (filterConditions.length === 1) { where = filterConditions[0] @@ -234,7 +212,7 @@ export function CollectionViewProvider({ connectionId, databaseName, collectionN } fetchData() - }, [connectionId, databaseName, collectionName, connections, collectionRefreshKey, currentPage, pageSize, searchTerm, activeFilter, refreshKey, getRows, t]) + }, [connectionId, databaseName, collectionName, connections, collectionRefreshKey, currentPage, pageSize, activeFilter, refreshKey, getRows, t]) // ---- Page change ---- const handlePageChange = useCallback((page: number) => { @@ -262,7 +240,6 @@ export function CollectionViewProvider({ connectionId, databaseName, collectionN pageSize, total, totalPages, - searchTerm, activeFilter, availableFields, showExportModal, @@ -275,7 +252,6 @@ export function CollectionViewProvider({ connectionId, databaseName, collectionN refresh: () => runWithDiscardGuard(refresh), handlePageChange, handlePageSizeChange, - setSearchTerm: setSearchTermGuarded, setIsFilterModalOpen, handleFilterApply, setShowExportModal, diff --git a/dataflow/src/components/database/mongodb/CollectionView/types.ts b/dataflow/src/components/database/mongodb/CollectionView/types.ts index f7c3dce3e..06fb4ee28 100644 --- a/dataflow/src/components/database/mongodb/CollectionView/types.ts +++ b/dataflow/src/components/database/mongodb/CollectionView/types.ts @@ -45,7 +45,6 @@ export interface CollectionViewState { pageSize: number total: number totalPages: number - searchTerm: string activeFilter: FlatMongoFilter availableFields: string[] showExportModal: boolean @@ -75,7 +74,6 @@ export interface CollectionViewActions { refresh: () => void handlePageChange: (page: number) => void handlePageSizeChange: (size: number) => void - setSearchTerm: (term: string) => void setIsFilterModalOpen: (open: boolean) => void handleFilterApply: (filter: FlatMongoFilter) => void setShowExportModal: (open: boolean) => void diff --git a/dataflow/src/components/database/shared/FindBar.Provider.tsx b/dataflow/src/components/database/shared/FindBar.Provider.tsx index 9bf038b6b..252347c8a 100644 --- a/dataflow/src/components/database/shared/FindBar.Provider.tsx +++ b/dataflow/src/components/database/shared/FindBar.Provider.tsx @@ -47,10 +47,6 @@ interface FindBarProviderProps { rows: Record[] | undefined /** Column keys to search. */ columns: string[] | undefined - /** Optional controlled search term. */ - searchTerm?: string - /** Optional controlled setter for integrating external search state. */ - onSearchTermChange?: (term: string) => void children: ReactNode } @@ -58,23 +54,11 @@ interface FindBarProviderProps { export function FindBarProvider({ rows, columns, - searchTerm: controlledSearchTerm, - onSearchTermChange, children, }: FindBarProviderProps) { - const [internalSearchTerm, setInternalSearchTerm] = useState('') + const [searchTerm, setSearchTerm] = useState('') const [currentMatchIndex, setCurrentMatchIndex] = useState(0) const inputRef = useRef(null) - const searchTerm = controlledSearchTerm ?? internalSearchTerm - - const setSearchTerm = useCallback((term: string) => { - if (onSearchTermChange) { - onSearchTermChange(term) - return - } - - setInternalSearchTerm(term) - }, [onSearchTermChange]) const matches = useMemo(() => { if (!searchTerm.trim() || !rows || !columns) return [] diff --git a/dataflow/src/components/database/sql/TableView/TableViewProvider.tsx b/dataflow/src/components/database/sql/TableView/TableViewProvider.tsx index 2af14e53c..006a174ca 100644 --- a/dataflow/src/components/database/sql/TableView/TableViewProvider.tsx +++ b/dataflow/src/components/database/sql/TableView/TableViewProvider.tsx @@ -37,7 +37,6 @@ interface TableViewProviderProps { /** Provider that owns all TableDetailView state, GraphQL operations, and handlers. */ export function TableViewProvider({ connectionId, databaseName, tableName, schema, children }: TableViewProviderProps) { // ---- UI state ---- - const [searchTerm, setSearchTerm] = useState('') const [currentPage, setCurrentPage] = useState(1) const [pageSize, setPageSize] = useState(50) @@ -73,7 +72,6 @@ export function TableViewProvider({ connectionId, databaseName, tableName, schem tableName, currentPage, pageSize, - searchTerm, sortColumn, sortDirection, filterConditions, @@ -135,7 +133,6 @@ export function TableViewProvider({ connectionId, databaseName, tableName, schem setFilterConditions([]) setSortColumn(null) setSortDirection(null) - setSearchTerm('') setCurrentPage(1) changesetActions.discardChanges() } @@ -153,14 +150,6 @@ export function TableViewProvider({ connectionId, databaseName, tableName, schem return () => window.removeEventListener('beforeunload', handleBeforeUnload) }, [changesetState.hasPendingChanges]) - // ---- Search submit (reset to page 1) ---- - const handleSearchSubmit = useCallback(() => { - runWithDiscardGuard(() => { - setCurrentPage(1) - queryActions.handleSubmitRequest(0) - }) - }, [queryActions.handleSubmitRequest, runWithDiscardGuard]) - // ---- Sorting ---- const handleSort = useCallback((column: string, direction: 'asc' | 'desc') => { runWithDiscardGuard(() => { @@ -207,7 +196,6 @@ export function TableViewProvider({ connectionId, databaseName, tableName, schem ...queryState, currentPage, pageSize, - searchTerm, visibleColumns, filterConditions, sortColumn, @@ -227,8 +215,6 @@ export function TableViewProvider({ connectionId, databaseName, tableName, schem handleSubmitRequest: queryActions.handleSubmitRequest, handlePageChange, handlePageSizeChange, - setSearchTerm, - handleSearchSubmit, handleSort, clearSort, setActiveColumnMenu, diff --git a/dataflow/src/components/database/sql/TableView/types.ts b/dataflow/src/components/database/sql/TableView/types.ts index cbc335c91..162128556 100644 --- a/dataflow/src/components/database/sql/TableView/types.ts +++ b/dataflow/src/components/database/sql/TableView/types.ts @@ -93,7 +93,6 @@ export interface TableViewState { pageSize: number total: number totalPages: number - searchTerm: string visibleColumns: string[] filterConditions: FilterCondition[] sortColumn: string | null @@ -125,8 +124,6 @@ export interface TableViewActions { handleSubmitRequest: (overridePageOffset?: number) => Promise handlePageChange: (page: number) => void handlePageSizeChange: (size: number) => void - setSearchTerm: (term: string) => void - handleSearchSubmit: () => void handleSort: (column: string, direction: 'asc' | 'desc') => void clearSort: () => void setActiveColumnMenu: (col: string | null) => void diff --git a/dataflow/src/components/database/sql/TableView/useDataQuery.ts b/dataflow/src/components/database/sql/TableView/useDataQuery.ts index 89c462c5b..cf5813948 100644 --- a/dataflow/src/components/database/sql/TableView/useDataQuery.ts +++ b/dataflow/src/components/database/sql/TableView/useDataQuery.ts @@ -9,7 +9,6 @@ import { } from '@graphql' import { transformRowsResult, type TableData } from '@/utils/graphql-transforms' import { resolveSchemaParam } from '@/utils/database-features' -import { parseSearchToWhereCondition, mergeSearchWithWhere } from '@/utils/search-parser' import { useI18n } from '@/i18n/useI18n' import type { FilterCondition } from './types' @@ -20,7 +19,6 @@ interface UseDataQueryParams { tableName: string currentPage: number pageSize: number - searchTerm: string sortColumn: string | null sortDirection: 'asc' | 'desc' | null filterConditions: FilterCondition[] @@ -57,7 +55,6 @@ export function useDataQuery(params: UseDataQueryParams): { state: DataQueryStat tableName, currentPage, pageSize, - searchTerm, sortColumn, sortDirection, filterConditions, @@ -78,20 +75,10 @@ export function useDataQuery(params: UseDataQueryParams): { state: DataQueryStat const latestRequestIdRef = useRef(0) const filterConditionsRef = useRef(filterConditions) - const columnsRef = useRef<{ names: string[]; types: string[] }>({ names: [], types: [] }) // Keep refs in sync useEffect(() => { filterConditionsRef.current = filterConditions }, [filterConditions]) - useEffect(() => { - if (data?.columns && data.columns.length > 0) { - columnsRef.current = { - names: data.columns, - types: data.columns.map(c => data.columnTypes[c] ?? 'string'), - } - } - }, [data?.columns, data?.columnTypes]) - const handleSubmitRequest = useCallback(async (overridePageOffset?: number) => { const conn = connections.find((c) => c.id === connectionId) if (!conn) return @@ -134,16 +121,7 @@ export function useDataQuery(params: UseDataQueryParams): { state: DataQueryStat } } - // Build search where condition - const searchWhere = searchTerm.trim() - ? parseSearchToWhereCondition( - searchTerm, - columnsRef.current.names, - columnsRef.current.types, - ) - : undefined - - const where = mergeSearchWithWhere(searchWhere, filterWhere) + const where = filterWhere try { const { data: result, error: queryError } = await getRows({ @@ -182,7 +160,7 @@ export function useDataQuery(params: UseDataQueryParams): { state: DataQueryStat setLoading(false) } } - }, [connections, connectionId, databaseName, schema, tableName, sortColumn, sortDirection, searchTerm, pageSize, currentPage, getRows, visibleColumnsCount, onInitVisibleColumns, t]) + }, [connections, connectionId, databaseName, schema, tableName, sortColumn, sortDirection, pageSize, currentPage, getRows, visibleColumnsCount, onInitVisibleColumns, t]) // Fetch on mount and when data-changing params change useEffect(() => { diff --git a/dataflow/src/utils/search-parser.ts b/dataflow/src/utils/search-parser.ts deleted file mode 100644 index 010bc55a4..000000000 --- a/dataflow/src/utils/search-parser.ts +++ /dev/null @@ -1,407 +0,0 @@ -import { WhereCondition, WhereConditionType } from '@graphql'; - -/** - * Parsed search query result - */ -export type ParsedSearch = { - type: 'column-specific' | 'full-text' | 'compound'; - column?: string; - operator?: string; - value?: string; - rawValue?: string; - compoundOperator?: 'AND' | 'OR'; - conditions?: ParsedSearch[]; -}; - -/** - * Parse a search string into structured query components - * Supports: - * - Column-specific: "id=1", "name = Alice", "age > 18" - * - Wildcards: "name=%Alice%", "email LIKE %@gmail.com" - * - Compound: "id=1 AND name=Alice", "age>18 OR status=active" - * - Full-text: "Alice" (searches all columns) - */ -export function parseSearchString(search: string): ParsedSearch { - const trimmed = search.trim(); - - if (!trimmed) { - return { type: 'full-text', rawValue: '' }; - } - - // Check for compound conditions (AND/OR) - // Split by AND/OR while preserving which operator was used - const compoundMatch = splitByLogicalOperators(trimmed); - - if (compoundMatch && compoundMatch.parts.length > 1) { - // Parse each part as an atomic condition, ensuring each part is trimmed - const conditions = compoundMatch.parts - .map(part => part.trim()) - .filter(part => part.length > 0) - .map(part => parseAtomicCondition(part)); - - // If all parts are valid column-specific conditions, create compound - if (conditions.every(c => c.type === 'column-specific')) { - return { - type: 'compound', - compoundOperator: compoundMatch.operator, - conditions, - rawValue: trimmed - }; - } - // Otherwise fall back to full-text search - return { type: 'full-text', rawValue: trimmed }; - } - - // No compound operators - parse as atomic condition - return parseAtomicCondition(trimmed); -} - -/** - * Parse a single atomic condition (no AND/OR) - */ -function parseAtomicCondition(search: string): ParsedSearch { - const trimmed = search.trim(); - - if (!trimmed) { - return { type: 'full-text', rawValue: '' }; - } - - // Match column-specific patterns: column operator value - // Operators: =, !=, <>, >, <, >=, <=, LIKE, NOT LIKE, IN, NOT IN, IS, IS NOT - const columnPattern = /^([a-zA-Z_][a-zA-Z0-9_]*)\s*(=|!=|<>|>=|<=|>|<|LIKE|NOT\s+LIKE|IN|NOT\s+IN|IS|IS\s+NOT)\s*(.+)$/i; - const match = trimmed.match(columnPattern); - - if (match) { - const [, column, operator, value] = match; - // Trim all components to handle cases like "likes_count=42 " with trailing space - const trimmedColumn = column.trim(); - const trimmedOperator = operator.trim().toUpperCase(); - const trimmedValue = value.trim(); - - // Validate that we have non-empty values after trimming - if (!trimmedColumn || !trimmedOperator || !trimmedValue) { - return { type: 'full-text', rawValue: trimmed }; - } - - return { - type: 'column-specific', - column: trimmedColumn, - operator: trimmedOperator, - value: trimmedValue, - rawValue: trimmed - }; - } - - // No column pattern found - treat as full-text search - return { - type: 'full-text', - rawValue: trimmed - }; -} - -/** - * Split search string by AND/OR operators - * Returns null if no operators found, otherwise returns parts and the operator used - */ -function splitByLogicalOperators(search: string): { operator: 'AND' | 'OR'; parts: string[] } | null { - // Look for AND operator (must be surrounded by spaces or at boundaries) - const andPattern = /\s+AND\s+/i; - const orPattern = /\s+OR\s+/i; - - const hasAnd = andPattern.test(search); - const hasOr = orPattern.test(search); - - if (hasAnd && hasOr) { - // Both AND and OR - not supported yet, treat as full-text - return null; - } - - if (hasAnd) { - const parts = search.split(andPattern); - if (parts.length > 1) { - return { operator: 'AND', parts }; - } - } - - if (hasOr) { - const parts = search.split(orPattern); - if (parts.length > 1) { - return { operator: 'OR', parts }; - } - } - - return null; -} - -/** - * Convert parsed search into a WhereCondition - * @param parsed - Parsed search result - * @param columns - Available column names for full-text search - * @param columnTypes - Column types (for type inference) - * @param validOperators - Valid operators for the database - */ -export function parseSearchToWhereCondition( - search: string, - columns: string[], - columnTypes?: (string | undefined)[], - validOperators?: string[] -): WhereCondition | undefined { - const parsed = parseSearchString(search); - - if (!parsed.rawValue) { - return undefined; - } - - // Handle compound conditions (AND/OR) - if (parsed.type === 'compound' && parsed.conditions && parsed.compoundOperator) { - const atomicConditions: WhereCondition[] = []; - - // Convert each parsed condition to a WhereCondition - for (const condition of parsed.conditions) { - if (condition.type === 'column-specific' && condition.column && condition.operator && condition.value) { - const atomicCondition = createAtomicCondition( - condition.column, - condition.operator, - condition.value, - columns, - columnTypes, - validOperators - ); - - if (atomicCondition) { - atomicConditions.push(atomicCondition); - } else { - // If any condition is invalid, fall back to full-text search - return createFullTextCondition(parsed.rawValue!, columns, columnTypes); - } - } - } - - // Create compound condition - if (atomicConditions.length === 0) { - return undefined; - } - - if (atomicConditions.length === 1) { - return atomicConditions[0]; - } - - if (parsed.compoundOperator === 'AND') { - return { - Type: WhereConditionType.And, - And: { - Children: atomicConditions - } - }; - } else { - return { - Type: WhereConditionType.Or, - Or: { - Children: atomicConditions - } - }; - } - } - - if (parsed.type === 'column-specific' && parsed.column && parsed.operator && parsed.value) { - return createAtomicCondition( - parsed.column, - parsed.operator, - parsed.value, - columns, - columnTypes, - validOperators - ) || createFullTextCondition(parsed.rawValue!, columns, columnTypes); - } - - // Full-text search across all text columns - return createFullTextCondition(parsed.rawValue!, columns, columnTypes); -} - -/** - * Create an atomic WHERE condition from column, operator, and value - */ -function createAtomicCondition( - column: string, - operator: string, - value: string, - columns: string[], - columnTypes?: (string | undefined)[], - validOperators?: string[] -): WhereCondition | undefined { - // Ensure all inputs are trimmed - const trimmedColumn = column.trim(); - const trimmedOperator = operator.trim(); - const trimmedValue = value.trim(); - - // Validate non-empty after trimming - if (!trimmedColumn || !trimmedOperator || !trimmedValue) { - return undefined; - } - - // Validate column exists - const columnIndex = columns.findIndex(col => - col.toLowerCase() === trimmedColumn.toLowerCase() - ); - - if (columnIndex === -1) { - return undefined; - } - - // Validate operator if validOperators provided - if (validOperators && !validOperators.includes(trimmedOperator)) { - return undefined; - } - - const actualColumn = columns[columnIndex]; - const columnType = columnTypes?.[columnIndex] || 'string'; - - // Clean up value - remove quotes if present - let cleanValue = trimmedValue; - if ((cleanValue.startsWith("'") && cleanValue.endsWith("'")) || - (cleanValue.startsWith('"') && cleanValue.endsWith('"'))) { - cleanValue = cleanValue.slice(1, -1); - } - - // Handle wildcards in LIKE operator - if (trimmedOperator === 'LIKE' || trimmedOperator === 'NOT LIKE') { - // Keep wildcards as-is (%, _) - // User can specify: name=%Alice% or name=Alice (we'll add % % for them) - if (!cleanValue.includes('%') && !cleanValue.includes('_')) { - cleanValue = `%${cleanValue}%`; - } - } - - return { - Type: WhereConditionType.Atomic, - Atomic: { - Key: actualColumn, - Operator: trimmedOperator, - Value: cleanValue, - ColumnType: inferColumnType(columnType) - } - }; -} - -/** - * Create a full-text search condition that searches across all suitable columns - */ -function createFullTextCondition( - searchText: string, - columns: string[], - columnTypes?: (string | undefined)[] -): WhereCondition | undefined { - // Filter to searchable columns (text-based types) - const searchableColumns = columns.filter((_, index) => { - const type = columnTypes?.[index]?.toLowerCase() || ''; - return isTextType(type); - }); - - if (searchableColumns.length === 0) { - // No searchable columns - search all columns - return createOrCondition(columns, searchText, columnTypes); - } - - return createOrCondition(searchableColumns, searchText, columnTypes); -} - -/** - * Create an OR condition across multiple columns - */ -function createOrCondition( - columns: string[], - searchText: string, - columnTypes?: (string | undefined)[] -): WhereCondition | undefined { - if (columns.length === 0) { - return undefined; - } - - const conditions: WhereCondition[] = columns.map((column, index) => { - const columnType = columnTypes?.[columns.indexOf(column)] || 'string'; - - return { - Type: WhereConditionType.Atomic, - Atomic: { - Key: column, - Operator: 'LIKE', - Value: `%${searchText}%`, - ColumnType: inferColumnType(columnType) - } - }; - }); - - if (conditions.length === 1) { - return conditions[0]; - } - - return { - Type: WhereConditionType.Or, - Or: { - Children: conditions - } - }; -} - -/** - * Check if a column type is text-based and searchable - */ -function isTextType(type: string): boolean { - const textTypes = [ - 'text', 'varchar', 'char', 'string', 'nvarchar', 'nchar', - 'clob', 'longtext', 'mediumtext', 'tinytext', - 'character', 'varying' - ]; - - return textTypes.some(t => type.includes(t)); -} - -/** - * Infer a simple column type for the GraphQL WhereCondition - */ -function inferColumnType(dbType: string): string { - const lower = dbType.toLowerCase(); - - if (lower.includes('int') || lower.includes('number') || lower.includes('decimal') || - lower.includes('float') || lower.includes('double') || lower.includes('numeric')) { - return 'number'; - } - - if (lower.includes('bool')) { - return 'boolean'; - } - - if (lower.includes('date') || lower.includes('time')) { - return 'date'; - } - - return 'string'; -} - -/** - * Merge search condition with existing where condition - */ -export function mergeSearchWithWhere( - searchCondition: WhereCondition | undefined, - whereCondition: WhereCondition | undefined -): WhereCondition | undefined { - if (!searchCondition && !whereCondition) { - return undefined; - } - - if (!searchCondition) { - return whereCondition; - } - - if (!whereCondition) { - return searchCondition; - } - - // Both exist - create AND condition - return { - Type: WhereConditionType.And, - And: { - Children: [whereCondition, searchCondition] - } - }; -} From 838c147f398c535341addeaa4aa79878c0f3c63b Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 2 Jun 2026 16:38:09 +0800 Subject: [PATCH 2/6] doc: update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 61ec832d2..5d89da5f5 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ cd dataflow pnpm install # Terminal 1: backend -cd ../core +cd core set -a source .env.local set +a From ddb78b48a364cff4e7ecd999847d584f8a2c9bd9 Mon Sep 17 00:00:00 2001 From: aimeritething Date: Tue, 2 Jun 2026 18:20:00 +0800 Subject: [PATCH 3/6] feat: add MongoDB collection table editing --- CONTEXT.md | 85 +++++ .../database/mongodb/CollectionDetailView.tsx | 45 ++- .../CollectionView.AddDocumentModal.tsx | 2 +- .../CollectionView.ColumnHeader.tsx | 107 ++++++ .../CollectionView.DocumentEditorDialog.tsx | 1 - .../CollectionView.FieldJsonEditorDialog.tsx | 63 ++++ .../CollectionView.TableGrid.tsx | 311 ++++++++++++++++++ .../CollectionView/CollectionView.Toolbar.tsx | 42 ++- .../CollectionView/CollectionViewProvider.tsx | 140 +++++++- .../CollectionView/mongo-table-utils.ts | 148 +++++++++ .../database/mongodb/CollectionView/types.ts | 25 ++ .../useDocumentChangesetManager.ts | 94 ++++-- .../mongodb/FilterCollectionModal.tsx | 3 + .../mongodb/FilterCollectionProvider.tsx | 17 +- dataflow/src/i18n/locales/en/mongodb.ts | 18 + dataflow/src/i18n/locales/zh/mongodb.ts | 18 + dataflow/src/test/mongodb-table-utils.test.ts | 101 ++++++ 17 files changed, 1152 insertions(+), 68 deletions(-) create mode 100644 CONTEXT.md create mode 100644 dataflow/src/components/database/mongodb/CollectionView/CollectionView.ColumnHeader.tsx create mode 100644 dataflow/src/components/database/mongodb/CollectionView/CollectionView.FieldJsonEditorDialog.tsx create mode 100644 dataflow/src/components/database/mongodb/CollectionView/CollectionView.TableGrid.tsx create mode 100644 dataflow/src/components/database/mongodb/CollectionView/mongo-table-utils.ts create mode 100644 dataflow/src/test/mongodb-table-utils.test.ts diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..3ef923406 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,85 @@ +# WhoDB Data Exploration Context + +WhoDB helps users inspect and manipulate database storage units across relational and document databases. This context records product language for database exploration views. + +## Language + +**MongoDB Collection**: +A MongoDB storage unit made of documents that may not all share the same fields. +_Avoid_: MongoDB table + +**MongoDB Document**: +A single record inside a **MongoDB Collection**. +_Avoid_: row, JSON row + +**JSON View**: +The MongoDB collection view that shows each **MongoDB Document** as editable JSON. +_Avoid_: card-only view + +**Collection Table View**: +A MongoDB collection view that presents documents in a grid using document fields as columns. +_Avoid_: MongoDB table + +**Sampled Field Set**: +A field list inferred from a limited sample of documents for the **Collection Table View**. +_Avoid_: complete schema + +**Unset Field**: +A field that exists as a column in the **Collection Table View** but is absent from a specific **MongoDB Document**. +_Avoid_: null, empty string + +**Editable Scalar Field**: +A top-level document field whose value can be edited directly in a **Collection Table View** cell. +_Avoid_: editable nested field + +**Complex Document Field**: +A top-level document field whose value is an object or array and should be edited through the full document editor. +_Avoid_: inline JSON cell + +**Field JSON Editor**: +A focused editor for changing a single object or array field from the **Collection Table View**. +_Avoid_: document table mode + +**Document JSON Editor**: +The editor for changing an entire **MongoDB Document** as JSON. +_Avoid_: document table mode, field-level editor + +## Relationships + +- A **MongoDB Collection** contains zero or more **MongoDB Documents**. +- A **JSON View** displays **MongoDB Documents** in their native document shape. +- A **Collection Table View** presents **MongoDB Documents** as rows while preserving MongoDB's flexible field model. +- A **Sampled Field Set** guides the columns shown in a **Collection Table View** but does not represent a complete MongoDB schema. +- An **Unset Field** is distinct from a field whose stored value is `null`. +- Editing an **Unset Field** creates that field on the affected **MongoDB Document**. +- An **Editable Scalar Field** can be edited inline in the **Collection Table View**. +- Editing an **Editable Scalar Field** preserves the existing field type when the field already exists. +- A **Complex Document Field** in the **Collection Table View** can open a **Field JSON Editor**. +- A **Field JSON Editor** accepts any valid JSON value, even when that changes an object or array field into a scalar or `null`. +- A **MongoDB Document** is edited through a **Document JSON Editor**. +- A **Complex Document Field** is not edited through a separate field-level interaction inside the **Document JSON Editor**. + +## Example Dialogue + +> **Dev:** "Should MongoDB open in the table by default?" +> **Domain expert:** "Yes. Open MongoDB collections in the **Collection Table View** by default because users expect a grid for browsing. Keep the **JSON View** available as a switchable document-focused view." + +## Flagged Ambiguities + +- "table view" in MongoDB means **Collection Table View**, not a relational database table. +- MongoDB inline editing is limited to **Editable Scalar Fields**; object and array cells in the **Collection Table View** open a **Field JSON Editor**. +- A **Complex Document Field** cell opens the **Field JSON Editor** by double-clicking the cell, without an extra edit icon. +- A **Field JSON Editor** is shown as a dialog, not as an expanded table cell. +- A **Field JSON Editor** validates JSON syntax only. It does not force the edited value to remain an object or array. +- Saving a **Field JSON Editor** writes to pending document changes, not directly to the database. +- Empty **Field JSON Editor** content is invalid JSON and does not delete the field. Field deletion must be a distinct action. +- The document editor should be a **Document JSON Editor**, not a table view, field list, or field-level editor. +- The **Collection Table View** is the default MongoDB collection view. +- The **Collection Table View** should build its first column set from a limited default sample, not by scanning the full collection. +- The **Collection Table View** keeps `_id` as the first column and orders other discovered fields alphabetically. +- The **Collection Table View** supports sorting and filtering on top-level document fields. +- Column filtering in the **Collection Table View** reuses the MongoDB filter builder, with column headers preselecting the target field. +- Switching between **Collection Table View** and **JSON View** preserves pending document changes. +- Pending changes from **Collection Table View** and **JSON View** share the same document-level preview and submission flow. +- Clearing an existing field in the **Collection Table View** should not delete the field; field deletion should be a distinct action. +- Editing a `null` or **Unset Field** in the **Collection Table View** creates a string value unless the user chooses a distinct typed action. diff --git a/dataflow/src/components/database/mongodb/CollectionDetailView.tsx b/dataflow/src/components/database/mongodb/CollectionDetailView.tsx index db6ade543..53269eff5 100644 --- a/dataflow/src/components/database/mongodb/CollectionDetailView.tsx +++ b/dataflow/src/components/database/mongodb/CollectionDetailView.tsx @@ -1,10 +1,12 @@ import { useMemo } from 'react' import { CollectionViewProvider, useCollectionView } from './CollectionView/CollectionViewProvider' import { CollectionViewDocumentList } from './CollectionView/CollectionView.DocumentList' +import { CollectionViewTableGrid } from './CollectionView/CollectionView.TableGrid' import { CollectionViewToolbar } from './CollectionView/CollectionView.Toolbar' import { AddDocumentModal } from './CollectionView/CollectionView.AddDocumentModal' import { EditDocumentModal } from './CollectionView/CollectionView.EditDocumentModal' import { buildPreviewCommands, summarizeChanges } from './CollectionView/changeset-mongo-preview' +import { buildRenderedMongoDocuments } from './CollectionView/mongo-table-utils' import { DataView } from '@/components/database/shared/DataView' import { FindBar } from '@/components/database/shared/FindBar' import { ExportCollectionModal } from './ExportCollectionModal' @@ -59,6 +61,8 @@ function CollectionDetailViewContent({ databaseName, collectionName, connectionI /** Extract all top-level field names from visible documents for FindBar. */ const docColumns = useMemo(() => { + if (state.viewMode === 'table') return state.tableColumns + const keys = new Set() state.documents.forEach((doc) => { if (typeof doc === 'object' && doc !== null) { @@ -66,7 +70,21 @@ function CollectionDetailViewContent({ databaseName, collectionName, connectionI } }) return Array.from(keys) - }, [state.documents]) + }, [state.documents, state.tableColumns, state.viewMode]) + + const findRows = useMemo(() => { + if (state.viewMode === 'table') { + const pageOffset = (state.currentPage - 1) * state.pageSize + return buildRenderedMongoDocuments({ + documents: state.documents as Record[], + changes: state.changes, + newRowOrder: state.newRowOrder, + pageOffset, + }).map((row) => row.doc) + } + + return state.documents + }, [state.changes, state.currentPage, state.documents, state.newRowOrder, state.pageSize, state.viewMode]) return (
) : ( -
0 ? 'ready' : 'empty'} - > - -
+ {state.viewMode === 'table' ? ( + + ) : ( +
0 ? 'ready' : 'empty'} + > + +
+ )}
)} @@ -149,6 +171,7 @@ function CollectionDetailViewContent({ databaseName, collectionName, connectionI onOpenChange={actions.setIsFilterModalOpen} onApply={actions.handleFilterApply} fields={state.availableFields} + preferredField={state.preferredFilterField} initialFilter={state.activeFilter} /> diff --git a/dataflow/src/components/database/mongodb/CollectionView/CollectionView.AddDocumentModal.tsx b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.AddDocumentModal.tsx index 7dc0190fb..920226978 100644 --- a/dataflow/src/components/database/mongodb/CollectionView/CollectionView.AddDocumentModal.tsx +++ b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.AddDocumentModal.tsx @@ -12,7 +12,7 @@ export function AddDocumentModal( title={t('mongodb.document.addTitle')} submitLabel={t('mongodb.document.add')} description={t('mongodb.document.addDescription')} - placeholder="{ ... }" + placeholder={t('mongodb.document.placeholder')} {...props} /> ) diff --git a/dataflow/src/components/database/mongodb/CollectionView/CollectionView.ColumnHeader.tsx b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.ColumnHeader.tsx new file mode 100644 index 000000000..96161c9f6 --- /dev/null +++ b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.ColumnHeader.tsx @@ -0,0 +1,107 @@ +import { + ArrowDownAZ, + ArrowUpAZ, + ListFilter, + MoreHorizontal, + X, +} from 'lucide-react' +import { Badge } from '@/components/ui/Badge' +import { Button } from '@/components/ui/Button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useI18n } from '@/i18n/useI18n' +import { cn } from '@/lib/utils' +import { useCollectionView } from './CollectionViewProvider' + +interface CollectionViewColumnHeaderProps { + column: string + index: number +} + +/** Renders a MongoDB collection table column header with sort and filter actions. */ +export function CollectionViewColumnHeader({ column, index }: CollectionViewColumnHeaderProps) { + const { t } = useI18n() + const { state, actions } = useCollectionView() + const isSorted = state.sortColumn === column + const hasFilter = Object.prototype.hasOwnProperty.call(state.activeFilter, column) + + return ( + +
+
+
+ {column} + {column === '_id' && ( + + {t('mongodb.table.idBadge')} + + )} + {isSorted && ( + + {state.sortDirection === 'asc' ? : } + + )} + {hasFilter && } +
+ + {t('mongodb.table.fieldType')} + +
+ + actions.setActiveColumnMenu(open ? column : null)} + > + + + + + + {t('mongodb.table.columnActions')} + + actions.handleSort(column, 'asc')} + className={cn(isSorted && state.sortDirection === 'asc' && 'bg-primary/5 font-medium text-primary')} + > + + {t('mongodb.table.sortAsc')} + + actions.handleSort(column, 'desc')} + className={cn(isSorted && state.sortDirection === 'desc' && 'bg-primary/5 font-medium text-primary')} + > + + {t('mongodb.table.sortDesc')} + + {isSorted && ( + actions.clearSort()}> + + {t('mongodb.table.clearSort')} + + )} + + actions.openFilterForField(column)}> + + {t('mongodb.table.filterColumn')} + + + +
+ + ) +} diff --git a/dataflow/src/components/database/mongodb/CollectionView/CollectionView.DocumentEditorDialog.tsx b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.DocumentEditorDialog.tsx index fab963f50..d882efe1f 100644 --- a/dataflow/src/components/database/mongodb/CollectionView/CollectionView.DocumentEditorDialog.tsx +++ b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.DocumentEditorDialog.tsx @@ -1,7 +1,6 @@ import { Dialog, DialogContent } from '@/components/ui/dialog' import { ModalForm } from '@/components/ui/ModalForm' import { Textarea } from '@/components/ui/Textarea' -import { useI18n } from '@/i18n/useI18n' export interface DocumentEditorDialogProps { open: boolean diff --git a/dataflow/src/components/database/mongodb/CollectionView/CollectionView.FieldJsonEditorDialog.tsx b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.FieldJsonEditorDialog.tsx new file mode 100644 index 000000000..abfc6488a --- /dev/null +++ b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.FieldJsonEditorDialog.tsx @@ -0,0 +1,63 @@ +import { Dialog, DialogContent } from '@/components/ui/dialog' +import { ModalForm } from '@/components/ui/ModalForm' +import { Textarea } from '@/components/ui/Textarea' +import { useI18n } from '@/i18n/useI18n' + +interface FieldJsonEditorDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + fieldName: string + content: string + onContentChange: (content: string) => void + onSave: () => Promise +} + +/** Dialog for editing one MongoDB document field as a JSON value. */ +export function FieldJsonEditorDialog({ + open, + onOpenChange, + fieldName, + content, + onContentChange, + onSave, +}: FieldJsonEditorDialogProps) { + const { t } = useI18n() + + return ( + + + + +