diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..e774e8292 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,88 @@ +# 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 not be edited inline in a **Collection Table View** cell. +_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, except when the input is a complete, valid, unquoted JSON object or array. +- 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 + +### Terminology + +- "table view" in MongoDB means **Collection Table View**, not a relational database table. +- 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** supports sorting and filtering on top-level document fields. + +### MongoDB Table Editing + +- MongoDB inline editing is limited to **Editable Scalar Fields**; object and array cells in the **Collection Table View** open a **Field JSON Editor**. +- A **Field JSON Editor** validates JSON syntax only. It does not force the edited value to remain an object or array. +- Empty input or clearing an existing field is not field deletion; field deletion must be a distinct action. +- Editing a `null` or **Unset Field** in the **Collection Table View** creates a string value unless the input is a complete, valid, unquoted JSON object or array. +- Typing a complete, valid, unquoted JSON object or array into any **Editable Scalar Field** changes that field into a **Complex Document Field**. + +### MongoDB View State + +- 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. 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 diff --git a/dataflow/docs/semantic-test-contract.md b/dataflow/docs/semantic-test-contract.md index 3292943e1..1a26c98d9 100644 --- a/dataflow/docs/semantic-test-contract.md +++ b/dataflow/docs/semantic-test-contract.md @@ -49,7 +49,7 @@ | SQL table row/cell | `src/components/database/sql/TableView/TableView.DataGrid.tsx` | `sql.table.row`, `sql.table.row-selector`, `sql.table.cell`, `sql.table.cell-editor` | item/field/action | Editable table rows and cells | `object=table-row/table-cell`, `field`, `resource-id=rowKey`, `state=ready/selected/inserted/deleted/editable/editing/changed/read_only`, `disabled-reason=read_only/primary_key/row_deleted` | Yes | Yes | rendered row state | High | | Shared data view filter/error | `src/components/database/shared/*` | `data-view.filter-button`, `data-view.error`, `data-view.retry-button` | action/error | Data filter and load failure retry | `module=data-view`, `object=filter/data-load`, `action=open/retry`, `state=active/inactive/error`, `error-code=data_load_failed` | Yes | Yes | shared data view state | Medium | | MongoDB collection detail | `src/components/database/mongodb/CollectionDetailView.tsx` | `mongodb.collection.detail`, `mongodb.collection.detail-loading` | panel/state | Collection document detail | `connection-id`, `database`, `resource-type=collection`, `resource-id=collectionName`, `state=ready/loading/error` | No | Yes | collection provider state | High | -| MongoDB toolbar actions | `src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx` | `mongodb.collection.*-button` | action | Refresh, add/delete document, undo, preview, submit, export, query, chart | `action=refresh/create/mark-delete/undo/preview/submit/export/open-query/create-chart`, `risk=resource_mutation`, `disabled-reason` | Yes | Yes | collection actions | High | +| MongoDB toolbar actions | `src/components/database/mongodb/CollectionView/CollectionView.Toolbar.tsx` | `mongodb.collection.*-button`, `mongodb.collection.view-toggle-button` | action | Refresh, switch collection view, add/delete document, undo, preview, submit, export, query, chart | `action=refresh/switch-to-table/switch-to-json/create/mark-delete/undo/preview/submit/export/open-query/create-chart`, `state=table/json`, `risk=resource_mutation`, `disabled-reason` | Yes | Yes | collection actions and view mode | High | | MongoDB document list/card | `src/components/database/mongodb/CollectionView/CollectionView.DocumentList.tsx` | `mongodb.collection.document-list-region`, `mongodb.collection.document-card`, `mongodb.collection.edit-document-button`, `mongodb.collection.document-list-empty` | item/action/state | Documents, edit action, empty list | `object=document/document-list`, `state=ready/selected/insert/update/delete/empty`, `resource-type=document`, `resource-id=rowKey` | Yes | Yes | document changeset state | High | | Redis key detail | `src/components/database/redis/RedisKeyDetailView.tsx` | `redis.key.detail`, `redis.key.detail-loading` | panel/state | Redis key value detail | `connection-id`, `database`, `resource-type=redis_key`, `resource-id=keyName`, `key-type`, `state=ready/loading/mutating/error` | No | Yes | Redis key rows | High | | Redis key toolbar actions | `src/components/database/redis/RedisKeyDetailView.tsx` | `redis.key.*-button` | action | Refresh, add/delete row, export, query, chart | `action=refresh/create/delete/export/open-query/create-chart`, `risk=resource_mutation`, `disabled-reason` | Yes | Yes | Redis key handlers | High | @@ -67,7 +67,7 @@ | Layout/activity/tab | `connections`, `analysis`, `active`, `inactive`, `dirty`, `empty` | Shell navigation state | | Sidebar node | `selected`, `idle`, `expanded`, `collapsed`, `leaf`, `loading` | Combined as a space-separated state string when multiple apply | | SQL editor/result | `ready`, `executing`, `completed`, `loading`, `success`, `error`, `empty` | Query execution and result display | -| SQL/Mongo/Redis mutation surfaces | `ready`, `loading`, `dirty`, `mutating`, `inserted`, `deleted`, `changed`, `selected`, `editable`, `editing`, `read_only`, `creating` | Table/document/key editing | +| SQL/Mongo/Redis mutation surfaces | `ready`, `loading`, `dirty`, `mutating`, `inserted`, `deleted`, `changed`, `selected`, `editable`, `editing`, `read_only`, `creating`, `table`, `json` | Table/document/key editing and MongoDB collection view mode | | Analysis dashboard/widget | `ready`, `empty`, `active`, `inactive`, `editable`, `read_only`, `idle`, `loading`, `success`, `error`, `editing` | Dashboard and widget runtime | ## 5. Disabled Reason Enums diff --git a/dataflow/src/components/database/mongodb/CollectionDetailView.tsx b/dataflow/src/components/database/mongodb/CollectionDetailView.tsx index b4c187b76..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'} + > + +
+ )}
)} @@ -151,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..9da6865d2 --- /dev/null +++ b/dataflow/src/components/database/mongodb/CollectionView/CollectionView.ColumnHeader.tsx @@ -0,0 +1,127 @@ +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) + const width = state.columnWidths[column] || 160 + + 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')} + + + +
+
{ + if (state.resizingColumn) return + document.querySelectorAll(`[data-resize-col="${column}"]`).forEach(element => { element.dataset.resizeActive = '' }) + }} + onMouseLeave={() => { + if (state.resizingColumn) return + document.querySelectorAll(`[data-resize-col="${column}"]`).forEach(element => { delete element.dataset.resizeActive }) + }} + onMouseDown={(event) => actions.handleResizeStart(event, column)} + /> + + ) +} 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 ( + + + + +