| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033 |
- // View/Schema Designer page (US-033)
- // Visual schema designer for creating custom views
- import { useState, useEffect, useCallback, useMemo } from 'react'
- import { useParams, useNavigate } from 'react-router-dom'
- import apiClient from '@/api/client'
- import { useWorkspace } from '@/contexts/WorkspaceContext'
- import Button from '@/components/Button'
- import Input from '@/components/Input'
- import GroupMultiSelect from '@/components/GroupMultiSelect'
- // Field type definitions matching backend
- const FIELD_TYPES = [
- { value: 'text', label: 'Text', icon: 'T' },
- { value: 'number', label: 'Number', icon: '#' },
- { value: 'email', label: 'Email', icon: '@' },
- { value: 'url', label: 'URL', icon: '🔗' },
- { value: 'date', label: 'Date', icon: '📅' },
- { value: 'datetime', label: 'Date & Time', icon: '🕐' },
- { value: 'boolean', label: 'Boolean', icon: '✓' },
- { value: 'select', label: 'Select', icon: '▼' },
- { value: 'reference', label: 'Reference', icon: '→' },
- ] as const
- const WIDGET_TYPES = [
- { value: '', label: 'Auto' },
- { value: 'textarea', label: 'Text Area' },
- { value: 'select', label: 'Dropdown' },
- { value: 'radio', label: 'Radio Buttons' },
- { value: 'checkbox', label: 'Checkbox' },
- { value: 'color', label: 'Color Picker' },
- ] as const
- interface SchemaField {
- _id: string // Client-side ID for React keys
- name: string
- type: string
- label: string
- description: string
- required: boolean
- display_order: number
- widget: string
- group: string
- default_value?: unknown
- options?: string[]
- reference_collection?: string
- }
- interface ViewSchema {
- title?: string
- description?: string
- layout?: unknown
- fields?: Omit<SchemaField, '_id'>[] // Full view detail has fields
- field_count?: number // List view has field_count
- }
- interface View {
- id: string
- name: string
- collection_name: string
- workspace_id: string
- schema: ViewSchema
- settings?: Record<string, unknown>
- created_at: string
- updated_at: string
- }
- interface Collection {
- name: string
- schema?: Record<string, unknown>
- is_system: boolean
- }
- // Field editor component
- function FieldEditor({
- field,
- onUpdate,
- onDelete,
- onMoveUp,
- onMoveDown,
- isFirst,
- isLast,
- collections,
- }: {
- field: SchemaField
- onUpdate: (field: SchemaField) => void
- onDelete: () => void
- onMoveUp: () => void
- onMoveDown: () => void
- isFirst: boolean
- isLast: boolean
- collections: Collection[]
- }) {
- const [isExpanded, setIsExpanded] = useState(false)
- const [optionsInput, setOptionsInput] = useState(field.options?.join('\n') || '')
- const handleOptionsChange = (value: string) => {
- setOptionsInput(value)
- const options = value.split('\n').filter(Boolean)
- onUpdate({ ...field, options: options.length > 0 ? options : undefined })
- }
- return (
- <div className="rounded-lg border border-gray-200 bg-white">
- {/* Field header */}
- <div className="flex items-center gap-2 border-b border-gray-100 p-3">
- {/* Drag handle placeholder */}
- <div className="cursor-move text-gray-400">
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
- </svg>
- </div>
- {/* Move buttons */}
- <div className="flex flex-col gap-0.5">
- <button
- onClick={onMoveUp}
- disabled={isFirst}
- className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-30"
- title="Move up"
- type="button"
- >
- <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
- </svg>
- </button>
- <button
- onClick={onMoveDown}
- disabled={isLast}
- className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-30"
- title="Move down"
- type="button"
- >
- <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
- </svg>
- </button>
- </div>
- {/* Type badge */}
- <span className="flex h-6 w-6 items-center justify-center rounded bg-gray-100 text-xs font-medium text-gray-600">
- {FIELD_TYPES.find((t) => t.value === field.type)?.icon || '?'}
- </span>
- {/* Field name and label */}
- <div className="flex-1">
- <span className="font-mono text-sm font-medium text-gray-900">{field.name}</span>
- {field.label && field.label !== field.name && (
- <span className="ml-2 text-sm text-gray-500">({field.label})</span>
- )}
- </div>
- {/* Required badge */}
- {field.required && (
- <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700">
- Required
- </span>
- )}
- {/* Type selector */}
- <select
- value={field.type}
- onChange={(e) => onUpdate({ ...field, type: e.target.value })}
- className="rounded border border-gray-200 px-2 py-1 text-sm"
- >
- {FIELD_TYPES.map((type) => (
- <option key={type.value} value={type.value}>
- {type.label}
- </option>
- ))}
- </select>
- {/* Expand/collapse */}
- <button
- onClick={() => setIsExpanded(!isExpanded)}
- className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
- type="button"
- >
- <svg
- className={`h-5 w-5 transition ${isExpanded ? 'rotate-180' : ''}`}
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- >
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
- </svg>
- </button>
- {/* Delete button */}
- <button
- onClick={onDelete}
- className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
- title="Delete field"
- type="button"
- >
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
- />
- </svg>
- </button>
- </div>
- {/* Field properties (expanded) */}
- {isExpanded && (
- <div className="grid gap-4 p-4 sm:grid-cols-2">
- <Input
- label="Display Label"
- value={field.label}
- onChange={(e) => onUpdate({ ...field, label: e.target.value })}
- placeholder={field.name}
- />
- <Input
- label="Description"
- value={field.description}
- onChange={(e) => onUpdate({ ...field, description: e.target.value })}
- placeholder="Help text for this field"
- />
- <div>
- <label className="mb-1.5 block text-sm font-medium text-gray-700">Widget</label>
- <select
- value={field.widget}
- onChange={(e) => onUpdate({ ...field, widget: e.target.value })}
- className="w-full rounded-lg border border-gray-300 px-3 py-2"
- >
- {WIDGET_TYPES.map((w) => (
- <option key={w.value} value={w.value}>
- {w.label}
- </option>
- ))}
- </select>
- </div>
- <Input
- label="Field Group"
- value={field.group}
- onChange={(e) => onUpdate({ ...field, group: e.target.value })}
- placeholder="e.g., Basic Info, Contact"
- />
- {field.type === 'select' && (
- <div className="sm:col-span-2">
- <label className="mb-1.5 block text-sm font-medium text-gray-700">
- Options (one per line)
- </label>
- <textarea
- value={optionsInput}
- onChange={(e) => handleOptionsChange(e.target.value)}
- placeholder="Option 1 Option 2 Option 3"
- rows={3}
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
- />
- </div>
- )}
- {field.type === 'reference' && (
- <div>
- <label className="mb-1.5 block text-sm font-medium text-gray-700">
- Reference Collection
- </label>
- <select
- value={field.reference_collection || ''}
- onChange={(e) => onUpdate({ ...field, reference_collection: e.target.value })}
- className="w-full rounded-lg border border-gray-300 px-3 py-2"
- >
- <option value="">Select collection...</option>
- {collections
- .filter((c) => !c.is_system)
- .map((c) => (
- <option key={c.name} value={c.name}>
- {c.name}
- </option>
- ))}
- </select>
- </div>
- )}
- <div className="flex items-center gap-4 sm:col-span-2">
- <label className="flex items-center gap-2">
- <input
- type="checkbox"
- checked={field.required}
- onChange={(e) => onUpdate({ ...field, required: e.target.checked })}
- className="h-4 w-4 rounded border-gray-300"
- />
- <span className="text-sm text-gray-700">Required field</span>
- </label>
- </div>
- </div>
- )}
- </div>
- )
- }
- // View form modal
- function ViewModal({
- view,
- collections,
- workspaceId,
- onClose,
- onSave,
- }: {
- view: View | null
- collections: Collection[]
- workspaceId: string
- onClose: () => void
- onSave: () => void
- }) {
- const isEditing = !!view
- const [name, setName] = useState(view?.name || '')
- const [collectionName, setCollectionName] = useState(view?.collection_name || '')
- const [fields, setFields] = useState<SchemaField[]>(() => {
- if (view?.schema?.fields) {
- return view.schema.fields.map((f, index) => ({
- _id: crypto.randomUUID(),
- name: f.name,
- type: f.type || 'text',
- label: f.label || '',
- description: f.description || '',
- required: f.required || false,
- display_order: f.display_order ?? index,
- widget: f.widget || '',
- group: f.group || '',
- default_value: f.default_value,
- options: f.options,
- reference_collection: f.reference_collection,
- }))
- }
- return []
- })
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState<string | null>(null)
- // Auto-create collection state
- const [autoCreateCollection, setAutoCreateCollection] = useState(false)
- // Access control state
- const [visibleToGroups, setVisibleToGroups] = useState<string[]>(
- (view?.settings?.visible_to_groups as string[]) || []
- )
- const [editableByGroups, setEditableByGroups] = useState<string[]>(
- (view?.settings?.editable_by_groups as string[]) || []
- )
- // Local state for field name editing to avoid re-renders
- const [editingFieldId, setEditingFieldId] = useState<string | null>(null)
- const [editingFieldName, setEditingFieldName] = useState('')
- const sortedFields = useMemo(
- () => [...fields].sort((a, b) => a.display_order - b.display_order),
- [fields]
- )
- const addField = () => {
- const newName = `field_${fields.length + 1}`
- setFields([
- ...fields,
- {
- _id: crypto.randomUUID(),
- name: newName,
- type: 'text',
- label: '',
- description: '',
- required: false,
- display_order: fields.length,
- widget: '',
- group: '',
- },
- ])
- }
- const updateField = (id: string, updated: SchemaField) => {
- setFields(fields.map((f) => (f._id === id ? updated : f)))
- }
- const deleteField = (id: string) => {
- setFields(fields.filter((f) => f._id !== id))
- }
- const moveField = (index: number, direction: 'up' | 'down') => {
- const field = sortedFields[index]
- const swapWith = sortedFields[direction === 'up' ? index - 1 : index + 1]
- if (!swapWith) return
- const fieldOrder = field.display_order
- const swapOrder = swapWith.display_order
- setFields(
- fields.map((f) => {
- if (f._id === field._id) return { ...f, display_order: swapOrder }
- if (f._id === swapWith._id) return { ...f, display_order: fieldOrder }
- return f
- })
- )
- }
- const startEditingFieldName = (field: SchemaField) => {
- setEditingFieldId(field._id)
- setEditingFieldName(field.name)
- }
- const finishEditingFieldName = () => {
- if (editingFieldId && editingFieldName.trim()) {
- const sanitized = editingFieldName.replace(/[^a-zA-Z0-9_]/g, '')
- if (sanitized && !fields.some((f) => f._id !== editingFieldId && f.name === sanitized)) {
- setFields(
- fields.map((f) => (f._id === editingFieldId ? { ...f, name: sanitized } : f))
- )
- }
- }
- setEditingFieldId(null)
- setEditingFieldName('')
- }
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
- if (!name.trim()) {
- setError('View name is required')
- return
- }
- // Use name as collection name if auto-create is enabled
- let targetCollectionName = collectionName
- if (autoCreateCollection && !isEditing) {
- targetCollectionName = name
- }
- if (!targetCollectionName) {
- setError('Please select a collection')
- return
- }
- setIsLoading(true)
- setError(null)
- try {
- // Auto-create collection if checkbox is checked
- if (autoCreateCollection && !isEditing) {
- try {
- await apiClient.post(`/workspaces/${workspaceId}/collections`, {
- name: name,
- settings: {},
- })
- } catch (err) {
- // Ignore "already exists" error, fail on others
- const message = err instanceof Error ? err.message : ''
- if (!message.includes('already exists')) {
- setError('Failed to create collection: ' + message)
- setIsLoading(false)
- return
- }
- }
- }
- // Build schema in backend format
- const schemaFields = sortedFields.map((f) => {
- const field: Omit<SchemaField, '_id'> = {
- name: f.name,
- type: f.type,
- label: f.label || f.name,
- description: f.description,
- required: f.required,
- display_order: f.display_order,
- widget: f.widget,
- group: f.group,
- }
- if (f.default_value !== undefined) {
- field.default_value = f.default_value
- }
- if (f.options && f.options.length > 0) {
- field.options = f.options
- }
- if (f.reference_collection) {
- field.reference_collection = f.reference_collection
- }
- return field
- })
- const payload = {
- name,
- collection_name: targetCollectionName,
- schema: {
- fields: schemaFields,
- },
- settings: {
- visible_to_groups: visibleToGroups,
- editable_by_groups: editableByGroups,
- },
- }
- if (isEditing && view) {
- await apiClient.request(`/workspaces/${workspaceId}/views/${view.id}`, {
- method: 'PATCH',
- body: payload,
- })
- } else {
- await apiClient.post(`/workspaces/${workspaceId}/views`, payload)
- }
- onSave()
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to save view')
- } finally {
- setIsLoading(false)
- }
- }
- return (
- <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4">
- <div className="max-h-[95vh] w-full max-w-4xl overflow-y-auto rounded-lg bg-white shadow-xl">
- <div className="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-6 py-4">
- <h2 className="text-xl font-semibold text-gray-900">
- {isEditing ? 'Edit View' : 'Create View'}
- </h2>
- <button onClick={onClose} className="rounded p-1 text-gray-400 hover:text-gray-600">
- <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M6 18L18 6M6 6l12 12"
- />
- </svg>
- </button>
- </div>
- <form onSubmit={handleSubmit}>
- <div className="p-6">
- {error && (
- <div className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
- )}
- <div className="grid gap-4 sm:grid-cols-2">
- <Input
- label="View Name"
- value={name}
- onChange={(e) => setName(e.target.value)}
- placeholder="e.g., Customer List"
- required
- />
- <div>
- <label className="mb-1.5 block text-sm font-medium text-gray-700">
- Collection
- <span className="ml-1 text-red-500">*</span>
- </label>
- <select
- value={autoCreateCollection ? name : collectionName}
- onChange={(e) => setCollectionName(e.target.value)}
- className="w-full rounded-lg border border-gray-300 px-3 py-2"
- required={!autoCreateCollection}
- disabled={isEditing || autoCreateCollection}
- >
- <option value="">Select a collection...</option>
- {collections
- .filter((c) => !c.is_system)
- .map((c) => (
- <option key={c.name} value={c.name}>
- {c.name}
- </option>
- ))}
- </select>
- {/* Auto-create collection checkbox */}
- {!isEditing && (
- <div className="mt-2 flex items-center gap-2">
- <input
- type="checkbox"
- id="auto-create-collection"
- checked={autoCreateCollection}
- onChange={(e) => {
- setAutoCreateCollection(e.target.checked)
- if (e.target.checked) {
- setCollectionName(name)
- }
- }}
- className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
- />
- <label htmlFor="auto-create-collection" className="text-sm text-gray-700">
- Create new collection with same name
- </label>
- </div>
- )}
- </div>
- </div>
- {/* Schema Designer */}
- <div className="mt-6">
- <div className="mb-3 flex items-center justify-between">
- <h3 className="text-lg font-medium text-gray-900">Fields</h3>
- <Button type="button" size="sm" onClick={addField}>
- <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
- </svg>
- Add Field
- </Button>
- </div>
- {fields.length === 0 ? (
- <div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
- <svg
- className="mx-auto h-12 w-12 text-gray-400"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- >
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={1}
- d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
- />
- </svg>
- <p className="mt-2 text-gray-500">No fields yet. Click "Add Field" to get started.</p>
- </div>
- ) : (
- <div className="space-y-3">
- {sortedFields.map((field, index) => (
- <div key={field._id}>
- <div className="mb-1 flex items-center gap-2">
- <span className="text-xs text-gray-500">Field name:</span>
- {editingFieldId === field._id ? (
- <input
- type="text"
- value={editingFieldName}
- onChange={(e) => setEditingFieldName(e.target.value)}
- onBlur={finishEditingFieldName}
- onKeyDown={(e) => {
- if (e.key === 'Enter') {
- e.preventDefault()
- finishEditingFieldName()
- }
- if (e.key === 'Escape') {
- setEditingFieldId(null)
- setEditingFieldName('')
- }
- }}
- autoFocus
- className="max-w-[150px] rounded border border-primary-300 px-2 py-0.5 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-primary-500"
- />
- ) : (
- <button
- type="button"
- onClick={() => startEditingFieldName(field)}
- className="max-w-[150px] truncate rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-700 hover:bg-gray-200"
- >
- {field.name}
- </button>
- )}
- </div>
- <FieldEditor
- field={field}
- onUpdate={(updated) => updateField(field._id, updated)}
- onDelete={() => deleteField(field._id)}
- onMoveUp={() => moveField(index, 'up')}
- onMoveDown={() => moveField(index, 'down')}
- isFirst={index === 0}
- isLast={index === sortedFields.length - 1}
- collections={collections}
- />
- </div>
- ))}
- </div>
- )}
- </div>
- {/* Access Control Section */}
- <div className="mt-6 border-t pt-6">
- <h3 className="mb-4 text-lg font-medium text-gray-900">Access Control</h3>
- <div className="space-y-4">
- <div>
- <label className="mb-2 block text-sm font-medium text-gray-700">
- Visible To
- </label>
- <p className="mb-2 text-xs text-gray-500">
- Select groups that can see this view. Leave empty for all users.
- </p>
- <GroupMultiSelect
- workspaceId={workspaceId}
- selectedGroups={visibleToGroups}
- onChange={setVisibleToGroups}
- />
- </div>
- <div>
- <label className="mb-2 block text-sm font-medium text-gray-700">
- Editable By
- </label>
- <p className="mb-2 text-xs text-gray-500">
- Select groups that can modify this view. Leave empty for workspace admins only.
- </p>
- <GroupMultiSelect
- workspaceId={workspaceId}
- selectedGroups={editableByGroups}
- onChange={setEditableByGroups}
- />
- </div>
- </div>
- </div>
- </div>
- <div className="sticky bottom-0 flex justify-end gap-3 border-t bg-gray-50 px-6 py-4">
- <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
- Cancel
- </Button>
- <Button type="submit" isLoading={isLoading}>
- {isEditing ? 'Save Changes' : 'Create View'}
- </Button>
- </div>
- </form>
- </div>
- </div>
- )
- }
- // Main Views page component
- function Views() {
- const { collectionName } = useParams<{ collectionName?: string }>()
- const navigate = useNavigate()
- const { currentWorkspace } = useWorkspace()
- const [views, setViews] = useState<View[]>([])
- const [collections, setCollections] = useState<Collection[]>([])
- const [isLoading, setIsLoading] = useState(true)
- const [error, setError] = useState<string | null>(null)
- const [searchQuery, setSearchQuery] = useState('')
- // Modals
- const [showCreateModal, setShowCreateModal] = useState(false)
- const [editingView, setEditingView] = useState<View | null>(null)
- const [deletingView, setDeletingView] = useState<View | null>(null)
- const [isLoadingViewDetails, setIsLoadingViewDetails] = useState(false)
- // Fetch full view details before editing
- const loadViewForEditing = useCallback(async (viewId: string) => {
- if (!currentWorkspace) return
- setIsLoadingViewDetails(true)
- try {
- const fullView = await apiClient.get<View>(
- `/workspaces/${currentWorkspace.id}/views/${viewId}`
- )
- setEditingView(fullView)
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load view details')
- } finally {
- setIsLoadingViewDetails(false)
- }
- }, [currentWorkspace])
- // Fetch collections for the dropdown
- const fetchCollections = useCallback(async () => {
- if (!currentWorkspace) return
- try {
- const response = await apiClient.get<{ collections: Collection[] }>(
- `/workspaces/${currentWorkspace.id}/collections`
- )
- setCollections(response.collections || [])
- } catch (err) {
- console.error('Failed to fetch collections:', err)
- }
- }, [currentWorkspace])
- // Fetch views
- const fetchViews = useCallback(async () => {
- if (!currentWorkspace) {
- setViews([])
- setIsLoading(false)
- return
- }
- setIsLoading(true)
- setError(null)
- try {
- let url = `/workspaces/${currentWorkspace.id}/views`
- if (collectionName) {
- url += `?collection=${encodeURIComponent(collectionName)}`
- }
- const response = await apiClient.get<{ views: View[] }>(url)
- setViews(response.views || [])
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to fetch views')
- } finally {
- setIsLoading(false)
- }
- }, [currentWorkspace, collectionName])
- useEffect(() => {
- fetchCollections()
- }, [fetchCollections])
- useEffect(() => {
- fetchViews()
- }, [fetchViews])
- const filteredViews = useMemo(() => {
- const query = searchQuery.toLowerCase()
- return views.filter(
- (v) => v.name.toLowerCase().includes(query) || v.collection_name.toLowerCase().includes(query)
- )
- }, [views, searchQuery])
- const handleDelete = async () => {
- if (!deletingView || !currentWorkspace) return
- try {
- await apiClient.delete(`/workspaces/${currentWorkspace.id}/views/${deletingView.id}`)
- setDeletingView(null)
- fetchViews()
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to delete view')
- }
- }
- const handleSave = () => {
- setShowCreateModal(false)
- setEditingView(null)
- fetchViews()
- }
- if (!currentWorkspace) {
- return (
- <div className="space-y-6">
- <div>
- <h1 className="text-2xl font-bold text-gray-900">Views</h1>
- <p className="mt-1 text-gray-600">Design custom views for your collections</p>
- </div>
- <div className="rounded-lg bg-yellow-50 p-6 text-center">
- <p className="text-yellow-800">Please select a workspace first.</p>
- </div>
- </div>
- )
- }
- return (
- <div className="space-y-6">
- {/* Header */}
- <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
- <div>
- <div className="flex items-center gap-2">
- {collectionName && (
- <button onClick={() => navigate('/views')} className="text-gray-400 hover:text-gray-600">
- <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
- </svg>
- </button>
- )}
- <h1 className="text-2xl font-bold text-gray-900">Views</h1>
- </div>
- <p className="mt-1 text-gray-600">
- {collectionName
- ? `Views for ${collectionName}`
- : `Design custom views in ${currentWorkspace.name}`}
- </p>
- </div>
- <Button onClick={() => setShowCreateModal(true)}>
- <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
- </svg>
- Create View
- </Button>
- </div>
- {/* Search */}
- <div className="max-w-md">
- <Input
- type="search"
- placeholder="Search views..."
- value={searchQuery}
- onChange={(e) => setSearchQuery(e.target.value)}
- />
- </div>
- {/* Error */}
- {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
- {/* Loading */}
- {isLoading && (
- <div className="flex items-center justify-center py-12">
- <svg
- className="h-8 w-8 animate-spin text-primary-600"
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- >
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
- <path
- className="opacity-75"
- fill="currentColor"
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
- />
- </svg>
- </div>
- )}
- {/* Views grid */}
- {!isLoading && !error && (
- <div>
- {filteredViews.length === 0 ? (
- <div className="rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
- <svg
- className="mx-auto h-12 w-12 text-gray-400"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- >
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={1}
- d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"
- />
- </svg>
- <p className="mt-4 text-gray-500">{searchQuery ? 'No views match your search' : 'No views yet'}</p>
- {!searchQuery && (
- <Button className="mt-4" onClick={() => setShowCreateModal(true)}>
- Create Your First View
- </Button>
- )}
- </div>
- ) : (
- <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
- {filteredViews.map((view) => (
- <div
- key={view.id}
- className="group relative rounded-lg border border-gray-200 bg-white p-4 transition hover:border-primary-300 hover:shadow-md"
- >
- <div className="flex items-start justify-between">
- <div>
- <h3 className="font-medium text-gray-900">{view.name}</h3>
- <p className="mt-1 text-sm text-gray-500">Collection: {view.collection_name}</p>
- <p className="mt-1 text-xs text-gray-400">
- {view.schema?.fields?.length ?? view.schema?.field_count ?? 0} fields
- </p>
- </div>
- <div className="flex gap-1 opacity-0 transition group-hover:opacity-100">
- <button
- onClick={() => loadViewForEditing(view.id)}
- className="rounded p-1 text-gray-400 hover:bg-primary-50 hover:text-primary-600"
- title="Edit"
- disabled={isLoadingViewDetails}
- >
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
- />
- </svg>
- </button>
- <button
- onClick={() => setDeletingView(view)}
- className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
- title="Delete"
- >
- <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path
- strokeLinecap="round"
- strokeLinejoin="round"
- strokeWidth={2}
- d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
- />
- </svg>
- </button>
- </div>
- </div>
- <div className="mt-3 flex flex-wrap gap-1">
- {(view.schema?.fields || []).slice(0, 4).map((field) => (
- <span key={field.name} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
- {field.name}
- </span>
- ))}
- {(view.schema?.fields?.length || 0) > 4 && (
- <span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
- +{(view.schema?.fields?.length || 0) - 4} more
- </span>
- )}
- </div>
- </div>
- ))}
- </div>
- )}
- </div>
- )}
- {/* Modals */}
- {(showCreateModal || editingView) && (
- <ViewModal
- view={editingView}
- collections={collections}
- workspaceId={currentWorkspace.id}
- onClose={() => {
- setShowCreateModal(false)
- setEditingView(null)
- }}
- onSave={handleSave}
- />
- )}
- {deletingView && (
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
- <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
- <h2 className="text-xl font-semibold text-gray-900">Delete View</h2>
- <p className="mt-2 text-gray-600">
- Are you sure you want to delete the view "{deletingView.name}"? This action cannot be undone.
- </p>
- <div className="mt-6 flex justify-end gap-3">
- <Button variant="secondary" onClick={() => setDeletingView(null)}>
- Cancel
- </Button>
- <Button variant="danger" onClick={handleDelete}>
- Delete View
- </Button>
- </div>
- </div>
- </div>
- )}
- </div>
- )
- }
- export default Views
|