| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939 |
- // 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'
- // 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)
- // 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
- }
- if (!collectionName) {
- setError('Please select a collection')
- return
- }
- setIsLoading(true)
- setError(null)
- try {
- // 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: collectionName,
- schema: {
- fields: schemaFields,
- },
- }
- 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={collectionName}
- onChange={(e) => setCollectionName(e.target.value)}
- className="w-full rounded-lg border border-gray-300 px-3 py-2"
- required
- disabled={isEditing}
- >
- <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>
- </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>
- </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
- key={editingView?.id || 'new'}
- 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
|