Просмотр исходного кода

feat(webui): document browser + Monaco JSON editor (full CRUD)

Fszontagh 2 месяцев назад
Родитель
Сommit
d6c0eb8e45
2 измененных файлов с 618 добавлено и 3 удалено
  1. 59 0
      webui/src/components/JsonEditor.tsx
  2. 559 3
      webui/src/pages/Documents.tsx

+ 59 - 0
webui/src/components/JsonEditor.tsx

@@ -0,0 +1,59 @@
+import Editor from '@monaco-editor/react'
+import { useEffect, useRef } from 'react'
+
+interface JsonEditorProps {
+  value: string
+  onChange: (value: string) => void
+  onValidityChange?: (valid: boolean) => void
+  height?: number
+  readOnly?: boolean
+}
+
+export function JsonEditor({
+  value,
+  onChange,
+  onValidityChange,
+  height = 360,
+  readOnly = false,
+}: JsonEditorProps) {
+  const lastValidRef = useRef<boolean>(true)
+
+  useEffect(() => {
+    let valid = false
+    try {
+      JSON.parse(value)
+      valid = true
+    } catch {
+      valid = false
+    }
+    if (valid !== lastValidRef.current) {
+      lastValidRef.current = valid
+      onValidityChange?.(valid)
+    }
+  }, [value, onValidityChange])
+
+  return (
+    <div
+      className="rounded-md overflow-hidden border border-slate-700/60"
+      style={{ height }}
+    >
+      <Editor
+        height={height}
+        language="json"
+        theme="vs-dark"
+        value={value}
+        onChange={(v) => onChange(v ?? '')}
+        options={{
+          minimap: { enabled: false },
+          fontSize: 13,
+          lineNumbers: 'on',
+          scrollBeyondLastLine: false,
+          wordWrap: 'on',
+          readOnly,
+          tabSize: 2,
+          automaticLayout: true,
+        }}
+      />
+    </div>
+  )
+}

+ 559 - 3
webui/src/pages/Documents.tsx

@@ -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>
   )
 }