|
|
@@ -1,8 +1,564 @@
|
|
|
+import { useState, useEffect } from 'react'
|
|
|
+import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
|
+import { Plus, Search, Pencil } from 'lucide-react'
|
|
|
+import { api } from '@/api/client'
|
|
|
+import { useAuthStore } from '@/stores/authStore'
|
|
|
+import { useToastStore } from '@/stores/toastStore'
|
|
|
+import { Modal } from '@/components/Modal'
|
|
|
+import { ConfirmDialog } from '@/components/ConfirmDialog'
|
|
|
+import { JsonEditor } from '@/components/JsonEditor'
|
|
|
+import { ApiError } from '@/types'
|
|
|
+import type { CollectionMeta } from '@/types'
|
|
|
+
|
|
|
+// ─── Filter op types ──────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+type FilterOp = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'contains' | 'exists' | 'regex' | 'search'
|
|
|
+
|
|
|
+const FILTER_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'contains', 'exists', 'regex', 'search']
|
|
|
+
|
|
|
+// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+function compactPreview(doc: Record<string, unknown>): string {
|
|
|
+ try {
|
|
|
+ const str = JSON.stringify(doc)
|
|
|
+ return str.length > 120 ? str.slice(0, 117) + '…' : str
|
|
|
+ } catch {
|
|
|
+ return '(unparseable)'
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildQueryString(
|
|
|
+ filterField: string,
|
|
|
+ filterOp: FilterOp,
|
|
|
+ filterValue: string,
|
|
|
+ limit: string,
|
|
|
+ offset: string,
|
|
|
+): string {
|
|
|
+ const parts: string[] = []
|
|
|
+
|
|
|
+ if (filterField.trim()) {
|
|
|
+ const encoded = encodeURIComponent(filterValue)
|
|
|
+ parts.push(`filter=${encodeURIComponent(filterField.trim())}:${filterOp}:${encoded}`)
|
|
|
+ }
|
|
|
+ if (limit.trim()) parts.push(`limit=${encodeURIComponent(limit.trim())}`)
|
|
|
+ if (offset.trim()) parts.push(`offset=${encodeURIComponent(offset.trim())}`)
|
|
|
+
|
|
|
+ return parts.length > 0 ? `?${parts.join('&')}` : ''
|
|
|
+}
|
|
|
+
|
|
|
+function docId(doc: Record<string, unknown>): string {
|
|
|
+ const id = doc['id'] ?? doc['_id']
|
|
|
+ if (typeof id === 'string') return id
|
|
|
+ if (typeof id === 'number') return String(id)
|
|
|
+ return JSON.stringify(id ?? '')
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Collection picker ────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+interface CollectionPickerProps {
|
|
|
+ project: string
|
|
|
+ selected: string
|
|
|
+ onSelect: (coll: string) => void
|
|
|
+}
|
|
|
+
|
|
|
+function CollectionPicker({ project, selected, onSelect }: CollectionPickerProps) {
|
|
|
+ const { data, isLoading } = useQuery<{ collections: CollectionMeta[] }>({
|
|
|
+ queryKey: ['collections', project],
|
|
|
+ queryFn: () => api.listCollections(project),
|
|
|
+ enabled: !!project,
|
|
|
+ })
|
|
|
+
|
|
|
+ const collections = data?.collections ?? []
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex items-center gap-3">
|
|
|
+ <label className="text-xs font-medium text-slate-400 shrink-0">Collection</label>
|
|
|
+ <select
|
|
|
+ value={selected}
|
|
|
+ onChange={(e) => onSelect(e.target.value)}
|
|
|
+ disabled={isLoading || collections.length === 0}
|
|
|
+ className="rounded-md bg-slate-800 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40 disabled:opacity-50"
|
|
|
+ data-testid="collection-picker"
|
|
|
+ >
|
|
|
+ {collections.length === 0 && !isLoading && (
|
|
|
+ <option value="">No collections</option>
|
|
|
+ )}
|
|
|
+ {isLoading && <option value="">Loading…</option>}
|
|
|
+ {collections.map((c) => (
|
|
|
+ <option key={c.name} value={c.name}>
|
|
|
+ {c.name} ({c.kind})
|
|
|
+ </option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Filter bar ───────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+interface FilterBarProps {
|
|
|
+ filterField: string
|
|
|
+ filterOp: FilterOp
|
|
|
+ filterValue: string
|
|
|
+ limit: string
|
|
|
+ offset: string
|
|
|
+ onFieldChange: (v: string) => void
|
|
|
+ onOpChange: (v: FilterOp) => void
|
|
|
+ onValueChange: (v: string) => void
|
|
|
+ onLimitChange: (v: string) => void
|
|
|
+ onOffsetChange: (v: string) => void
|
|
|
+ onSearch: () => void
|
|
|
+}
|
|
|
+
|
|
|
+function FilterBar({
|
|
|
+ filterField, filterOp, filterValue,
|
|
|
+ limit, offset,
|
|
|
+ onFieldChange, onOpChange, onValueChange,
|
|
|
+ onLimitChange, onOffsetChange,
|
|
|
+ onSearch,
|
|
|
+}: FilterBarProps) {
|
|
|
+ const inputClass =
|
|
|
+ 'rounded-md bg-slate-800 border border-slate-600 text-slate-200 text-sm px-3 py-1.5 placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40'
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex flex-wrap items-center gap-2">
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ placeholder="field"
|
|
|
+ value={filterField}
|
|
|
+ onChange={(e) => onFieldChange(e.target.value)}
|
|
|
+ className={`${inputClass} w-28`}
|
|
|
+ data-testid="filter-field"
|
|
|
+ />
|
|
|
+ <select
|
|
|
+ value={filterOp}
|
|
|
+ onChange={(e) => onOpChange(e.target.value as FilterOp)}
|
|
|
+ className={`${inputClass}`}
|
|
|
+ data-testid="filter-op"
|
|
|
+ >
|
|
|
+ {FILTER_OPS.map((op) => (
|
|
|
+ <option key={op} value={op}>{op}</option>
|
|
|
+ ))}
|
|
|
+ </select>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ placeholder="value"
|
|
|
+ value={filterValue}
|
|
|
+ onChange={(e) => onValueChange(e.target.value)}
|
|
|
+ className={`${inputClass} w-36`}
|
|
|
+ data-testid="filter-value"
|
|
|
+ />
|
|
|
+ <span className="text-slate-600 text-xs">|</span>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ placeholder="limit"
|
|
|
+ value={limit}
|
|
|
+ onChange={(e) => onLimitChange(e.target.value)}
|
|
|
+ min={1}
|
|
|
+ className={`${inputClass} w-20`}
|
|
|
+ />
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ placeholder="offset"
|
|
|
+ value={offset}
|
|
|
+ onChange={(e) => onOffsetChange(e.target.value)}
|
|
|
+ min={0}
|
|
|
+ className={`${inputClass} w-20`}
|
|
|
+ />
|
|
|
+ <button
|
|
|
+ onClick={onSearch}
|
|
|
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition"
|
|
|
+ data-testid="filter-search-btn"
|
|
|
+ >
|
|
|
+ <Search size={14} />
|
|
|
+ Search
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Document editor modal ────────────────────────────────────────────────────
|
|
|
+
|
|
|
+interface DocEditorModalProps {
|
|
|
+ open: boolean
|
|
|
+ title: string
|
|
|
+ initialJson: string
|
|
|
+ onClose: () => void
|
|
|
+ onSave: (parsed: Record<string, unknown>) => Promise<void>
|
|
|
+ onDelete?: () => void
|
|
|
+ saving: boolean
|
|
|
+}
|
|
|
+
|
|
|
+function DocEditorModal({
|
|
|
+ open, title, initialJson,
|
|
|
+ onClose, onSave, onDelete, saving,
|
|
|
+}: DocEditorModalProps) {
|
|
|
+ const [json, setJson] = useState(initialJson)
|
|
|
+ const [jsonValid, setJsonValid] = useState(true)
|
|
|
+ const [showDelete, setShowDelete] = useState(false)
|
|
|
+
|
|
|
+ // Sync editor content whenever the parent supplies fresh data (e.g. row clicked)
|
|
|
+ useEffect(() => {
|
|
|
+ setJson(initialJson)
|
|
|
+ setJsonValid(true)
|
|
|
+ setShowDelete(false)
|
|
|
+ }, [initialJson, open])
|
|
|
+
|
|
|
+ const handleSave = async () => {
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(json) as Record<string, unknown>
|
|
|
+ await onSave(parsed)
|
|
|
+ } catch {
|
|
|
+ // parse error — shouldn't happen since Save is disabled when invalid
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <Modal open={open} title={title} onClose={onClose}>
|
|
|
+ <div className="flex flex-col gap-4">
|
|
|
+ <JsonEditor
|
|
|
+ value={json}
|
|
|
+ onChange={setJson}
|
|
|
+ onValidityChange={setJsonValid}
|
|
|
+ height={380}
|
|
|
+ />
|
|
|
+
|
|
|
+ {!jsonValid && (
|
|
|
+ <p className="text-xs text-red-400">Invalid JSON — fix before saving.</p>
|
|
|
+ )}
|
|
|
+
|
|
|
+ <div className="flex items-center justify-between pt-1">
|
|
|
+ <div>
|
|
|
+ {onDelete && (
|
|
|
+ <button
|
|
|
+ onClick={() => setShowDelete(true)}
|
|
|
+ className="px-3 py-1.5 rounded-md text-sm font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 transition"
|
|
|
+ data-testid="doc-delete-btn"
|
|
|
+ >
|
|
|
+ Delete
|
|
|
+ </button>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <div className="flex gap-3">
|
|
|
+ <button
|
|
|
+ onClick={onClose}
|
|
|
+ className="px-4 py-2 rounded-md text-sm font-medium text-slate-300 hover:text-slate-100 bg-slate-700/50 hover:bg-slate-700 transition"
|
|
|
+ >
|
|
|
+ Cancel
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => void handleSave()}
|
|
|
+ disabled={!jsonValid || saving}
|
|
|
+ className="px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
+ data-testid="doc-save-btn"
|
|
|
+ >
|
|
|
+ {saving ? 'Saving…' : 'Save'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </Modal>
|
|
|
+
|
|
|
+ {onDelete && (
|
|
|
+ <ConfirmDialog
|
|
|
+ open={showDelete}
|
|
|
+ title="Delete Document"
|
|
|
+ message="Are you sure you want to permanently delete this document?"
|
|
|
+ confirmLabel="Delete"
|
|
|
+ onConfirm={() => {
|
|
|
+ setShowDelete(false)
|
|
|
+ onDelete()
|
|
|
+ }}
|
|
|
+ onClose={() => setShowDelete(false)}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Results table ────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+interface ResultsTableProps {
|
|
|
+ docs: Record<string, unknown>[]
|
|
|
+ onRowClick: (doc: Record<string, unknown>) => void
|
|
|
+}
|
|
|
+
|
|
|
+function ResultsTable({ docs, onRowClick }: ResultsTableProps) {
|
|
|
+ if (docs.length === 0) {
|
|
|
+ return (
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
|
|
|
+ <p className="text-slate-400 text-sm">No documents found.</p>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="rounded-xl border border-slate-700/40 overflow-hidden">
|
|
|
+ <table className="w-full text-sm">
|
|
|
+ <thead>
|
|
|
+ <tr className="border-b border-slate-700/40 bg-slate-800/60">
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider w-40">ID</th>
|
|
|
+ <th className="text-left px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider">Preview</th>
|
|
|
+ <th className="text-right px-4 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider w-16">Edit</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ {docs.map((doc, idx) => (
|
|
|
+ <tr
|
|
|
+ key={idx}
|
|
|
+ onClick={() => onRowClick(doc)}
|
|
|
+ className={[
|
|
|
+ 'border-b border-slate-700/20 cursor-pointer transition hover:bg-indigo-500/5',
|
|
|
+ idx % 2 === 0 ? 'bg-slate-900/30' : 'bg-slate-800/20',
|
|
|
+ ].join(' ')}
|
|
|
+ data-testid="doc-row"
|
|
|
+ >
|
|
|
+ <td className="px-4 py-3 font-mono text-slate-300 text-xs truncate max-w-xs">
|
|
|
+ {docId(doc)}
|
|
|
+ </td>
|
|
|
+ <td className="px-4 py-3 font-mono text-slate-400 text-xs truncate max-w-md">
|
|
|
+ {compactPreview(doc)}
|
|
|
+ </td>
|
|
|
+ <td className="px-4 py-3 text-right">
|
|
|
+ <Pencil size={13} className="inline text-slate-500" />
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+type ModalMode = 'new' | 'edit'
|
|
|
+
|
|
|
+interface ModalState {
|
|
|
+ mode: ModalMode
|
|
|
+ docId: string
|
|
|
+ json: string
|
|
|
+}
|
|
|
+
|
|
|
export default function Documents() {
|
|
|
+ const currentProject = useAuthStore((s) => s.currentProject)
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
+ const queryClient = useQueryClient()
|
|
|
+
|
|
|
+ // Collection selection
|
|
|
+ const [selectedColl, setSelectedColl] = useState('')
|
|
|
+
|
|
|
+ // Filter state
|
|
|
+ const [filterField, setFilterField] = useState('')
|
|
|
+ const [filterOp, setFilterOp] = useState<FilterOp>('eq')
|
|
|
+ const [filterValue, setFilterValue] = useState('')
|
|
|
+ const [limit, setLimit] = useState('50')
|
|
|
+ const [offset, setOffset] = useState('0')
|
|
|
+
|
|
|
+ // The query string used for the last triggered search
|
|
|
+ const [appliedQs, setAppliedQs] = useState('')
|
|
|
+
|
|
|
+ // Modal state
|
|
|
+ const [modalOpen, setModalOpen] = useState(false)
|
|
|
+ const [modalState, setModalState] = useState<ModalState>({ mode: 'new', docId: '', json: '{}' })
|
|
|
+ const [saving, setSaving] = useState(false)
|
|
|
+
|
|
|
+ // Auto-select first collection on project change
|
|
|
+ const { data: collectionsData } = useQuery<{ collections: CollectionMeta[] }>({
|
|
|
+ queryKey: ['collections', currentProject],
|
|
|
+ queryFn: () => api.listCollections(currentProject!),
|
|
|
+ enabled: !!currentProject,
|
|
|
+ })
|
|
|
+
|
|
|
+ const collections = collectionsData?.collections ?? []
|
|
|
+ const effectiveCollection = selectedColl || collections[0]?.name || ''
|
|
|
+
|
|
|
+ // Document search query
|
|
|
+ const findQueryKey = ['documents', currentProject, effectiveCollection, appliedQs]
|
|
|
+ const {
|
|
|
+ data: findData,
|
|
|
+ isLoading: findLoading,
|
|
|
+ isError: findError,
|
|
|
+ error: findErrorObj,
|
|
|
+ } = useQuery({
|
|
|
+ queryKey: findQueryKey,
|
|
|
+ queryFn: () => api.findDocuments(currentProject!, effectiveCollection, appliedQs),
|
|
|
+ enabled: !!currentProject && !!effectiveCollection,
|
|
|
+ })
|
|
|
+
|
|
|
+ const docs = findData?.documents ?? []
|
|
|
+ const docCount = findData?.count ?? null
|
|
|
+
|
|
|
+ const invalidateDocs = () => {
|
|
|
+ void queryClient.invalidateQueries({ queryKey: ['documents', currentProject, effectiveCollection] })
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleSearch = () => {
|
|
|
+ const qs = buildQueryString(filterField, filterOp, filterValue, limit, offset)
|
|
|
+ setAppliedQs(qs)
|
|
|
+ }
|
|
|
+
|
|
|
+ const handleCollectionSelect = (coll: string) => {
|
|
|
+ setSelectedColl(coll)
|
|
|
+ setAppliedQs('')
|
|
|
+ }
|
|
|
+
|
|
|
+ // Open existing doc for editing
|
|
|
+ const handleRowClick = async (doc: Record<string, unknown>) => {
|
|
|
+ if (!currentProject || !effectiveCollection) return
|
|
|
+ const id = docId(doc)
|
|
|
+ try {
|
|
|
+ const fullDoc = await api.getDocument(currentProject, effectiveCollection, id)
|
|
|
+ setModalState({
|
|
|
+ mode: 'edit',
|
|
|
+ docId: id,
|
|
|
+ json: JSON.stringify(fullDoc, null, 2),
|
|
|
+ })
|
|
|
+ setModalOpen(true)
|
|
|
+ } catch (err) {
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
+ push(`Failed to load document: ${msg}`, 'error')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Save (insert or update)
|
|
|
+ const handleSave = async (parsed: Record<string, unknown>) => {
|
|
|
+ if (!currentProject || !effectiveCollection) return
|
|
|
+ setSaving(true)
|
|
|
+ try {
|
|
|
+ if (modalState.mode === 'new') {
|
|
|
+ await api.insertDocument(currentProject, effectiveCollection, parsed)
|
|
|
+ push('Document inserted.', 'success')
|
|
|
+ } else {
|
|
|
+ await api.putDocument(currentProject, effectiveCollection, modalState.docId, parsed)
|
|
|
+ push('Document saved.', 'success')
|
|
|
+ }
|
|
|
+ setModalOpen(false)
|
|
|
+ invalidateDocs()
|
|
|
+ } catch (err) {
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
+ push(`Save failed: ${msg}`, 'error')
|
|
|
+ } finally {
|
|
|
+ setSaving(false)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Delete
|
|
|
+ const handleDelete = async () => {
|
|
|
+ if (!currentProject || !effectiveCollection || !modalState.docId) return
|
|
|
+ try {
|
|
|
+ await api.deleteDocument(currentProject, effectiveCollection, modalState.docId)
|
|
|
+ push('Document deleted.', 'success')
|
|
|
+ setModalOpen(false)
|
|
|
+ invalidateDocs()
|
|
|
+ } catch (err) {
|
|
|
+ const msg = err instanceof ApiError ? err.message : String(err)
|
|
|
+ push(`Delete failed: ${msg}`, 'error')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─── guard: no project selected ───────────────────────────────────────────
|
|
|
+
|
|
|
+ if (!currentProject) {
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col gap-8">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-2xl font-semibold text-slate-100">Documents</h1>
|
|
|
+ <p className="text-slate-400 text-sm mt-1">Browse, search, and edit documents.</p>
|
|
|
+ </div>
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center">
|
|
|
+ <p className="text-slate-400 text-sm">
|
|
|
+ No project selected — choose a project from the selector above.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─── render ───────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
return (
|
|
|
- <div>
|
|
|
- <h1 className="text-2xl font-semibold text-slate-100 mb-2">Documents</h1>
|
|
|
- <p className="text-slate-400">Document browser and editor — coming in W7.</p>
|
|
|
+ <div className="flex flex-col gap-6">
|
|
|
+ {/* Page header */}
|
|
|
+ <div className="flex items-center justify-between flex-wrap gap-3">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-2xl font-semibold text-slate-100">Documents</h1>
|
|
|
+ <p className="text-slate-400 text-sm mt-1">
|
|
|
+ Project: <span className="text-slate-300 font-mono">{currentProject}</span>
|
|
|
+ {docCount !== null && (
|
|
|
+ <span className="ml-2 text-slate-500">
|
|
|
+ — {docCount.toLocaleString()} result{docCount !== 1 ? 's' : ''}
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={() => {
|
|
|
+ setModalState({ mode: 'new', docId: '', json: '{}' })
|
|
|
+ setModalOpen(true)
|
|
|
+ }}
|
|
|
+ disabled={!effectiveCollection}
|
|
|
+ className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
|
|
|
+ data-testid="new-doc-btn"
|
|
|
+ >
|
|
|
+ <Plus size={16} />
|
|
|
+ New document
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Controls row */}
|
|
|
+ <div className="flex flex-col gap-3 rounded-xl border border-slate-700/40 bg-slate-800/30 px-4 py-3">
|
|
|
+ <CollectionPicker
|
|
|
+ project={currentProject}
|
|
|
+ selected={effectiveCollection}
|
|
|
+ onSelect={handleCollectionSelect}
|
|
|
+ />
|
|
|
+ <FilterBar
|
|
|
+ filterField={filterField}
|
|
|
+ filterOp={filterOp}
|
|
|
+ filterValue={filterValue}
|
|
|
+ limit={limit}
|
|
|
+ offset={offset}
|
|
|
+ onFieldChange={setFilterField}
|
|
|
+ onOpChange={setFilterOp}
|
|
|
+ onValueChange={setFilterValue}
|
|
|
+ onLimitChange={setLimit}
|
|
|
+ onOffsetChange={setOffset}
|
|
|
+ onSearch={handleSearch}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Results */}
|
|
|
+ {findLoading && (
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/30 px-6 py-10 text-center animate-pulse">
|
|
|
+ <p className="text-slate-400 text-sm">Loading documents…</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {findError && (
|
|
|
+ <div className="rounded-xl border border-red-500/20 bg-red-500/5 px-6 py-4">
|
|
|
+ <p className="text-red-400 text-sm">
|
|
|
+ Failed to load documents:{' '}
|
|
|
+ {findErrorObj instanceof Error ? findErrorObj.message : 'Unknown error'}
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {!findLoading && !findError && (
|
|
|
+ <ResultsTable docs={docs} onRowClick={(doc) => void handleRowClick(doc)} />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* Editor modal */}
|
|
|
+ <DocEditorModal
|
|
|
+ open={modalOpen}
|
|
|
+ title={modalState.mode === 'new' ? 'New Document' : `Edit Document — ${modalState.docId}`}
|
|
|
+ initialJson={modalState.json}
|
|
|
+ onClose={() => setModalOpen(false)}
|
|
|
+ onSave={(parsed) => handleSave(parsed)}
|
|
|
+ onDelete={modalState.mode === 'edit' ? () => void handleDelete() : undefined}
|
|
|
+ saving={saving}
|
|
|
+ />
|
|
|
</div>
|
|
|
)
|
|
|
}
|