Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ cd dataflow
pnpm install

# Terminal 1: backend
cd ../core
cd core
set -a
source .env.local
set +a
Expand Down
4 changes: 2 additions & 2 deletions dataflow/docs/semantic-test-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
47 changes: 34 additions & 13 deletions dataflow/src/components/database/mongodb/CollectionDetailView.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -59,14 +61,30 @@ 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<string>()
state.documents.forEach((doc) => {
if (typeof doc === 'object' && doc !== null) {
Object.keys(doc).forEach((k) => keys.add(k))
}
})
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<string, unknown>[],
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 (
<div
Expand All @@ -87,21 +105,23 @@ function CollectionDetailViewContent({ databaseName, collectionName, connectionI
<DataView.Error message={state.error} />
) : (
<FindBar.Provider
rows={state.documents}
rows={findRows}
columns={docColumns}
searchTerm={state.searchTerm}
onSearchTermChange={actions.setSearchTerm}
>
<FindBar.Bar />
<div
className="flex-1 overflow-auto p-4 space-y-4"
data-testid="mongodb.collection.document-list-region"
data-qa-module="mongodb"
data-qa-object="document-list"
data-qa-state={state.documents.length > 0 ? 'ready' : 'empty'}
>
<CollectionViewDocumentList />
</div>
{state.viewMode === 'table' ? (
<CollectionViewTableGrid />
) : (
<div
className="flex-1 overflow-auto p-4 space-y-4"
data-testid="mongodb.collection.document-list-region"
data-qa-module="mongodb"
data-qa-object="document-list"
data-qa-state={state.documents.length > 0 ? 'ready' : 'empty'}
>
<CollectionViewDocumentList />
</div>
)}
</FindBar.Provider>
)}

Expand Down Expand Up @@ -151,6 +171,7 @@ function CollectionDetailViewContent({ databaseName, collectionName, connectionI
onOpenChange={actions.setIsFilterModalOpen}
onApply={actions.handleFilterApply}
fields={state.availableFields}
preferredField={state.preferredFilterField}
initialFilter={state.activeFilter}
/>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<th
style={{ minWidth: `${width}px`, ...(state.resizedColumns.has(column) && { maxWidth: `${width}px` }) }}
className="sticky top-0 z-40 overflow-hidden border-b border-r border-border/50 bg-background px-4 py-2 text-left text-sm font-medium text-muted-foreground select-none"
>
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 flex-col">
<div className="flex min-w-0 items-center gap-1">
<span className="truncate" title={column}>{column}</span>
{column === '_id' && (
<Badge variant="secondary" className="h-4 shrink-0 px-1 py-0 text-[10px]">
{t('mongodb.table.idBadge')}
</Badge>
)}
{isSorted && (
<span className="shrink-0 text-primary">
{state.sortDirection === 'asc' ? <ArrowUpAZ className="h-3 w-3" /> : <ArrowDownAZ className="h-3 w-3" />}
</span>
)}
{hasFilter && <ListFilter className="h-3 w-3 shrink-0 text-primary" />}
</div>
<span className="truncate text-xs font-normal text-muted-foreground/80">
{t('mongodb.table.fieldType')}
</span>
</div>

<DropdownMenu
open={state.activeColumnMenu === column}
onOpenChange={(open) => actions.setActiveColumnMenu(open ? column : null)}
>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
className={cn(
'shrink-0 text-muted-foreground',
state.activeColumnMenu === column && 'bg-muted text-foreground',
)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={index === 0 ? 'start' : 'end'} className="w-44">
<DropdownMenuLabel className="text-[10px] text-muted-foreground">
{t('mongodb.table.columnActions')}
</DropdownMenuLabel>
<DropdownMenuItem
onSelect={() => actions.handleSort(column, 'asc')}
className={cn(isSorted && state.sortDirection === 'asc' && 'bg-primary/5 font-medium text-primary')}
>
<ArrowUpAZ className="h-3.5 w-3.5" />
{t('mongodb.table.sortAsc')}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => actions.handleSort(column, 'desc')}
className={cn(isSorted && state.sortDirection === 'desc' && 'bg-primary/5 font-medium text-primary')}
>
<ArrowDownAZ className="h-3.5 w-3.5" />
{t('mongodb.table.sortDesc')}
</DropdownMenuItem>
{isSorted && (
<DropdownMenuItem onSelect={() => actions.clearSort()}>
<X className="h-3.5 w-3.5" />
{t('mongodb.table.clearSort')}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => actions.openFilterForField(column)}>
<ListFilter className="h-3.5 w-3.5" />
{t('mongodb.table.filterColumn')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
data-resize-col={column}
className={cn(
'absolute right-0 top-0 -bottom-px z-20 w-1 cursor-col-resize data-[resize-active]:bg-primary/50',
state.resizingColumn === column && 'bg-primary/50',
)}
onMouseEnter={() => {
if (state.resizingColumn) return
document.querySelectorAll<HTMLElement>(`[data-resize-col="${column}"]`).forEach(element => { element.dataset.resizeActive = '' })
}}
onMouseLeave={() => {
if (state.resizingColumn) return
document.querySelectorAll<HTMLElement>(`[data-resize-col="${column}"]`).forEach(element => { delete element.dataset.resizeActive })
}}
onMouseDown={(event) => actions.handleResizeStart(event, column)}
/>
</th>
)
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading