| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628 |
- // 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 { useRealtimeDocuments } from '@/hooks/useRealtimeDocuments'
- import Button from '@/components/Button'
- import Input from '@/components/Input'
- import ViewPermissionsEditor from '@/components/ViewPermissionsEditor'
- // 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: '→' },
- { value: 'color', label: 'Color', icon: '🎨' },
- ] as const
- // Widget types per field type - only show relevant widgets
- const WIDGET_TYPES_BY_FIELD: Record<string, Array<{ value: string; label: string }>> = {
- text: [
- { value: '', label: 'Auto (Single line)' },
- { value: 'textarea', label: 'Text Area (Multi-line)' },
- ],
- number: [
- { value: '', label: 'Auto (Number input)' },
- { value: 'slider', label: 'Slider' },
- ],
- email: [
- { value: '', label: 'Auto (Email input)' },
- ],
- url: [
- { value: '', label: 'Auto (URL input)' },
- ],
- date: [
- { value: '', label: 'Auto (Date picker)' },
- ],
- datetime: [
- { value: '', label: 'Auto (Date & Time picker)' },
- ],
- boolean: [
- { value: '', label: 'Auto (Checkbox)' },
- { value: 'switch', label: 'Toggle Switch' },
- ],
- select: [
- { value: '', label: 'Auto (Dropdown)' },
- { value: 'radio', label: 'Radio Buttons' },
- ],
- reference: [
- { value: '', label: 'Auto (Dropdown)' },
- { value: 'autocomplete', label: 'Autocomplete' },
- ],
- color: [
- { value: '', label: 'Auto (Color picker)' },
- ],
- }
- // Get widgets for a field type
- const getWidgetsForType = (fieldType: string) => {
- return WIDGET_TYPES_BY_FIELD[fieldType] || [{ value: '', label: 'Auto' }]
- }
- // Convert camelCase or PascalCase to Title Case
- // e.g., kisCica -> Kis Cica, thisIsAnExample -> This Is An Example
- const camelToTitleCase = (str: string): string => {
- if (!str) return ''
- // Insert space before uppercase letters, then capitalize first letter of each word
- return str
- .replace(/([A-Z])/g, ' $1') // Add space before uppercase
- .replace(/^./, (c) => c.toUpperCase()) // Capitalize first letter
- .trim()
- }
- // Built-in system fields that can be displayed in views
- const SYSTEM_FIELDS = [
- { name: '_id', label: 'Document ID', type: 'text', description: 'Unique identifier' },
- { name: '_created_at', label: 'Created At', type: 'datetime', description: 'Creation timestamp' },
- { name: '_updated_at', label: 'Updated At', type: 'datetime', description: 'Last update timestamp' },
- { name: '_created_by_name', label: 'Created By', type: 'text', description: 'Name of user who created' },
- { name: '_updated_by_name', label: 'Updated By', type: 'text', description: 'Name of user who last updated' },
- ] 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
- markdown?: boolean // Enable markdown editing/rendering for textarea fields
- // List view display options
- hidden?: boolean // Hide field from list/table views (still visible in edit/create forms)
- maxLength?: number // Maximum characters to display in list views (truncate with ellipsis)
- // Detail view display options
- hidden_in_detail?: boolean // Hide field from single item detail view
- detail_widget?: string // Widget to use in detail view (defaults to same as edit widget)
- }
- 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, markdown: e.target.value !== 'textarea' ? false : field.markdown })}
- className="w-full rounded-lg border border-gray-300 px-3 py-2"
- >
- {getWidgetsForType(field.type).map((w) => (
- <option key={w.value} value={w.value}>
- {w.label}
- </option>
- ))}
- </select>
- </div>
- {/* Markdown option - only visible for textarea widget */}
- {field.widget === 'textarea' && (
- <div className="flex items-center">
- <label className="flex items-center gap-2">
- <input
- type="checkbox"
- checked={field.markdown || false}
- onChange={(e) => onUpdate({ ...field, markdown: e.target.checked })}
- className="h-4 w-4 rounded border-gray-300"
- />
- <span className="text-sm text-gray-700">Enable Markdown</span>
- </label>
- <span className="ml-2 text-xs text-gray-500">(WYSIWYG editor)</span>
- </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>
- {/* List View Display Options */}
- <div className="sm:col-span-2 border-t pt-4 mt-2">
- <h4 className="text-sm font-medium text-gray-700 mb-3">List View Display</h4>
- <div className="grid gap-4 sm:grid-cols-2">
- <label className="flex items-center gap-2">
- <input
- type="checkbox"
- checked={field.hidden || false}
- onChange={(e) => onUpdate({ ...field, hidden: e.target.checked })}
- className="h-4 w-4 rounded border-gray-300"
- />
- <div>
- <span className="text-sm text-gray-700">Hide in list view</span>
- <p className="text-xs text-gray-500">Field won't show in tables/cards but is visible in forms</p>
- </div>
- </label>
- <div>
- <label className="mb-1.5 block text-sm font-medium text-gray-700">Max display length</label>
- <input
- type="number"
- value={field.maxLength || ''}
- onChange={(e) => onUpdate({ ...field, maxLength: e.target.value ? parseInt(e.target.value) : undefined })}
- placeholder="No limit"
- min={1}
- className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
- />
- <p className="mt-1 text-xs text-gray-500">Truncate long content with ellipsis</p>
- </div>
- </div>
- </div>
- {/* Detail View Display Options */}
- <div className="sm:col-span-2 border-t pt-4 mt-2">
- <h4 className="text-sm font-medium text-gray-700 mb-3">Detail View Display</h4>
- <div className="grid gap-4 sm:grid-cols-2">
- <label className="flex items-center gap-2">
- <input
- type="checkbox"
- checked={field.hidden_in_detail || false}
- onChange={(e) => onUpdate({ ...field, hidden_in_detail: e.target.checked })}
- className="h-4 w-4 rounded border-gray-300"
- />
- <div>
- <span className="text-sm text-gray-700">Hide in detail view</span>
- <p className="text-xs text-gray-500">Field won't show when viewing a single item</p>
- </div>
- </label>
- <div>
- <label className="mb-1.5 block text-sm font-medium text-gray-700">Detail display style</label>
- <select
- value={field.detail_widget || ''}
- onChange={(e) => onUpdate({ ...field, detail_widget: e.target.value || undefined })}
- className="w-full rounded-lg border border-gray-300 px-3 py-2"
- >
- <option value="">Same as edit widget</option>
- <option value="readonly">Read-only text</option>
- <option value="badge">Badge/Tag</option>
- <option value="link">Clickable link</option>
- {(field.type === 'text' || field.type === 'textarea' || field.widget === 'textarea') && (
- <option value="markdown">Render as Markdown</option>
- )}
- {(field.type === 'text' || field.type === 'textarea' || field.widget === 'textarea') && (
- <option value="html">Render as HTML</option>
- )}
- {field.type === 'color' && (
- <option value="swatch">Color swatch</option>
- )}
- </select>
- </div>
- </div>
- </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) {
- // Filter out system fields (starting with _) - they are managed separately
- return view.schema.fields
- .filter(f => !f.name.startsWith('_'))
- .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,
- markdown: f.markdown,
- // List view display options
- hidden: f.hidden,
- maxLength: f.maxLength,
- // Detail view display options
- hidden_in_detail: f.hidden_in_detail,
- detail_widget: f.detail_widget,
- }))
- }
- return []
- })
- const [isLoading, setIsLoading] = useState(false)
- const [error, setError] = useState<string | null>(null)
- // Create settings state
- const [showCreateButton, setShowCreateButton] = useState<boolean>(
- view?.settings?.show_create_button !== false
- )
- const [quickCreateMode, setQuickCreateMode] = useState<string>(
- (view?.settings?.quick_create_mode as string) || 'modal'
- )
- const [quickCreateFields, setQuickCreateFields] = useState<string[]>(
- (view?.settings?.quick_create_fields as string[]) || []
- )
- // Edit settings state
- const [showEditButton, setShowEditButton] = useState<boolean>(
- view?.settings?.show_edit_button !== false
- )
- const [quickEditMode, setQuickEditMode] = useState<string>(
- (view?.settings?.quick_edit_mode as string) || 'modal'
- )
- const [quickEditFields, setQuickEditFields] = useState<string[]>(
- (view?.settings?.quick_edit_fields as string[]) || []
- )
- // Detail view settings state
- const [showDetailButton, setShowDetailButton] = useState<boolean>(
- view?.settings?.show_detail_button === true
- )
- const [quickDetailMode, setQuickDetailMode] = useState<string>(
- (view?.settings?.quick_detail_mode as string) || 'modal'
- )
- // System fields state - built-in document metadata fields
- const [enabledSystemFields, setEnabledSystemFields] = useState<string[]>(() => {
- if (view?.schema?.fields) {
- // Check which system fields are already in the schema (use Set to avoid duplicates)
- const systemFieldNames = ['_id', '_created_at', '_updated_at', '_created_by_name', '_updated_by_name']
- const enabledSet = new Set<string>()
- view.schema.fields.forEach(f => {
- if (systemFieldNames.includes(f.name)) {
- enabledSet.add(f.name)
- }
- })
- return Array.from(enabledSet)
- }
- return []
- })
- // 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 moveSystemField = (index: number, direction: 'up' | 'down') => {
- const newIndex = direction === 'up' ? index - 1 : index + 1
- if (newIndex < 0 || newIndex >= enabledSystemFields.length) return
- const newOrder = [...enabledSystemFields]
- const [removed] = newOrder.splice(index, 1)
- newOrder.splice(newIndex, 0, removed)
- setEnabledSystemFields(newOrder)
- }
- 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, '')
- // Prevent field names starting with _ (reserved for system fields)
- if (sanitized.startsWith('_')) {
- setError('Field names starting with "_" are reserved for system fields')
- setEditingFieldId(null)
- setEditingFieldName('')
- return
- }
- if (sanitized && !fields.some((f) => f._id !== editingFieldId && f.name === sanitized)) {
- setFields(
- fields.map((f) => {
- if (f._id === editingFieldId) {
- // Auto-fill label from field name if label is empty
- const newLabel = f.label || camelToTitleCase(sanitized)
- return { ...f, name: sanitized, label: newLabel }
- }
- return 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 - custom fields
- const customFields = 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
- }
- if (f.markdown) {
- field.markdown = f.markdown
- }
- // List view display options
- if (f.hidden) {
- field.hidden = f.hidden
- }
- if (f.maxLength) {
- field.maxLength = f.maxLength
- }
- // Detail view display options
- if (f.hidden_in_detail) {
- field.hidden_in_detail = f.hidden_in_detail
- }
- if (f.detail_widget) {
- field.detail_widget = f.detail_widget
- }
- return field
- })
- // Add enabled system fields (at the end with high display_order)
- const systemFieldsToInclude = enabledSystemFields.map((fieldName, index) => {
- const systemField = SYSTEM_FIELDS.find(sf => sf.name === fieldName)
- return {
- name: fieldName,
- type: systemField?.type || 'text',
- label: systemField?.label || fieldName,
- description: systemField?.description || '',
- required: false,
- display_order: 1000 + index, // Put system fields at the end
- widget: '',
- group: '_system',
- }
- })
- const schemaFields = [...customFields, ...systemFieldsToInclude]
- const payload = {
- name,
- collection_name: collectionName,
- schema: {
- fields: schemaFields,
- },
- settings: {
- show_create_button: showCreateButton,
- quick_create_mode: quickCreateMode,
- quick_create_fields: quickCreateFields.filter(f =>
- schemaFields.some(sf => sf.name === f)
- ),
- show_edit_button: showEditButton,
- quick_edit_mode: quickEditMode,
- quick_edit_fields: quickEditFields.filter(f =>
- schemaFields.some(sf => sf.name === f)
- ),
- // Detail view settings
- show_detail_button: showDetailButton,
- quick_detail_mode: quickDetailMode,
- },
- }
- 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>
- {/* System Fields */}
- <div className="mt-6">
- <h3 className="mb-3 text-lg font-medium text-gray-900">System Fields</h3>
- <p className="mb-3 text-sm text-gray-500">
- Include built-in document metadata fields in your view
- </p>
- <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
- {SYSTEM_FIELDS.map((sysField) => (
- <label
- key={sysField.name}
- className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${
- enabledSystemFields.includes(sysField.name)
- ? 'border-primary-500 bg-primary-50'
- : 'border-gray-200 hover:border-gray-300'
- }`}
- >
- <input
- type="checkbox"
- checked={enabledSystemFields.includes(sysField.name)}
- onChange={(e) => {
- if (e.target.checked) {
- setEnabledSystemFields([...enabledSystemFields, sysField.name])
- } else {
- setEnabledSystemFields(enabledSystemFields.filter(f => f !== sysField.name))
- }
- }}
- className="mt-0.5 h-4 w-4 rounded border-gray-300"
- />
- <div>
- <span className="font-medium text-gray-900">{sysField.label}</span>
- <p className="text-xs text-gray-500">{sysField.description}</p>
- </div>
- </label>
- ))}
- </div>
- {/* Enabled system fields order */}
- {enabledSystemFields.length >= 1 && (
- <div className="mt-4">
- <p className="mb-2 text-sm font-medium text-gray-700">
- {enabledSystemFields.length === 1 ? 'Selected Field' : 'Display Order'}
- </p>
- <div className="space-y-2">
- {enabledSystemFields.map((fieldName, index) => {
- const sysField = SYSTEM_FIELDS.find(sf => sf.name === fieldName)
- if (!sysField) return null
- return (
- <div
- key={fieldName}
- className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
- >
- <div className="flex flex-col">
- <button
- type="button"
- onClick={() => moveSystemField(index, 'up')}
- disabled={index === 0}
- className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
- title="Move up"
- >
- <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
- type="button"
- onClick={() => moveSystemField(index, 'down')}
- disabled={index === enabledSystemFields.length - 1}
- className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
- title="Move down"
- >
- <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>
- <span className="text-sm font-medium text-gray-700">{sysField.label}</span>
- </div>
- )
- })}
- </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">Custom 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>
- {/* Settings Section */}
- <div className="mt-6 border-t pt-6">
- <h3 className="mb-4 text-lg font-medium text-gray-900">Display Settings</h3>
- <div className="space-y-4">
- {/* Show create button toggle */}
- <label className="flex items-center gap-3">
- <input
- type="checkbox"
- checked={showCreateButton}
- onChange={(e) => setShowCreateButton(e.target.checked)}
- className="h-4 w-4 rounded border-gray-300"
- />
- <div>
- <span className="font-medium text-gray-900">Show create button</span>
- <p className="text-sm text-gray-500">
- Allow users to add new items directly from this view
- </p>
- </div>
- </label>
- {/* Quick create mode selector - only show when create button is enabled */}
- {showCreateButton && (
- <div>
- <label className="mb-2 block text-sm font-medium text-gray-700">
- Create form style
- </label>
- <div className="grid gap-3 sm:grid-cols-3">
- <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickCreateMode === 'modal' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
- <input
- type="radio"
- name="quickCreateMode"
- value="modal"
- checked={quickCreateMode === 'modal'}
- onChange={(e) => setQuickCreateMode(e.target.value)}
- className="mt-0.5 h-4 w-4"
- />
- <div>
- <span className="font-medium text-gray-900">Modal</span>
- <p className="text-xs text-gray-500">Popup dialog</p>
- </div>
- </label>
- <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickCreateMode === 'inline' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
- <input
- type="radio"
- name="quickCreateMode"
- value="inline"
- checked={quickCreateMode === 'inline'}
- onChange={(e) => setQuickCreateMode(e.target.value)}
- className="mt-0.5 h-4 w-4"
- />
- <div>
- <span className="font-medium text-gray-900">Inline</span>
- <p className="text-xs text-gray-500">Form on page</p>
- </div>
- </label>
- <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickCreateMode === 'page' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
- <input
- type="radio"
- name="quickCreateMode"
- value="page"
- checked={quickCreateMode === 'page'}
- onChange={(e) => setQuickCreateMode(e.target.value)}
- className="mt-0.5 h-4 w-4"
- />
- <div>
- <span className="font-medium text-gray-900">Full page</span>
- <p className="text-xs text-gray-500">Dedicated page</p>
- </div>
- </label>
- </div>
- </div>
- )}
- {/* Quick create fields - only show when create button is enabled and mode is not page */}
- {showCreateButton && quickCreateMode !== 'page' && sortedFields.length > 0 && (
- <div>
- <label className="mb-2 block text-sm font-medium text-gray-700">
- Quick create fields
- </label>
- <p className="mb-3 text-sm text-gray-500">
- Select which fields to show in the {quickCreateMode === 'inline' ? 'inline form' : 'modal'}. Leave empty to show all fields.
- </p>
- <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
- {sortedFields.filter(f => !f.name.startsWith('_')).map((field) => (
- <label key={field._id} className="flex items-center gap-2">
- <input
- type="checkbox"
- checked={quickCreateFields.includes(field.name)}
- onChange={(e) => {
- if (e.target.checked) {
- setQuickCreateFields([...quickCreateFields, field.name])
- } else {
- setQuickCreateFields(quickCreateFields.filter(f => f !== field.name))
- }
- }}
- className="h-4 w-4 rounded border-gray-300"
- />
- <span className="text-sm text-gray-700">
- {field.label || field.name}
- </span>
- </label>
- ))}
- </div>
- </div>
- )}
- {/* Divider between Create and Edit settings */}
- <div className="border-t border-gray-200 pt-4">
- <h4 className="mb-4 font-medium text-gray-900">Edit Settings</h4>
- </div>
- {/* Show edit button toggle */}
- <label className="flex items-center gap-3">
- <input
- type="checkbox"
- checked={showEditButton}
- onChange={(e) => setShowEditButton(e.target.checked)}
- className="h-4 w-4 rounded border-gray-300"
- />
- <div>
- <span className="font-medium text-gray-900">Show edit button</span>
- <p className="text-sm text-gray-500">
- Allow users to edit items from this view
- </p>
- </div>
- </label>
- {/* Quick edit mode selector - only show when edit button is enabled */}
- {showEditButton && (
- <div>
- <label className="mb-2 block text-sm font-medium text-gray-700">
- Edit form style
- </label>
- <div className="grid gap-3 sm:grid-cols-3">
- <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickEditMode === 'modal' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
- <input
- type="radio"
- name="quickEditMode"
- value="modal"
- checked={quickEditMode === 'modal'}
- onChange={(e) => setQuickEditMode(e.target.value)}
- className="mt-0.5 h-4 w-4"
- />
- <div>
- <span className="font-medium text-gray-900">Modal</span>
- <p className="text-xs text-gray-500">Popup dialog</p>
- </div>
- </label>
- <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickEditMode === 'inline' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
- <input
- type="radio"
- name="quickEditMode"
- value="inline"
- checked={quickEditMode === 'inline'}
- onChange={(e) => setQuickEditMode(e.target.value)}
- className="mt-0.5 h-4 w-4"
- />
- <div>
- <span className="font-medium text-gray-900">Inline</span>
- <p className="text-xs text-gray-500">Edit in place</p>
- </div>
- </label>
- <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickEditMode === 'page' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
- <input
- type="radio"
- name="quickEditMode"
- value="page"
- checked={quickEditMode === 'page'}
- onChange={(e) => setQuickEditMode(e.target.value)}
- className="mt-0.5 h-4 w-4"
- />
- <div>
- <span className="font-medium text-gray-900">Full page</span>
- <p className="text-xs text-gray-500">Dedicated page</p>
- </div>
- </label>
- </div>
- </div>
- )}
- {/* Quick edit fields - only show when edit button is enabled and mode is not page */}
- {showEditButton && quickEditMode !== 'page' && sortedFields.length > 0 && (
- <div>
- <label className="mb-2 block text-sm font-medium text-gray-700">
- Quick edit fields
- </label>
- <p className="mb-3 text-sm text-gray-500">
- Select which fields to show in the {quickEditMode === 'inline' ? 'inline form' : 'modal'}. Leave empty to show all fields.
- </p>
- <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
- {sortedFields.filter(f => !f.name.startsWith('_')).map((field) => (
- <label key={field._id} className="flex items-center gap-2">
- <input
- type="checkbox"
- checked={quickEditFields.includes(field.name)}
- onChange={(e) => {
- if (e.target.checked) {
- setQuickEditFields([...quickEditFields, field.name])
- } else {
- setQuickEditFields(quickEditFields.filter(f => f !== field.name))
- }
- }}
- className="h-4 w-4 rounded border-gray-300"
- />
- <span className="text-sm text-gray-700">
- {field.label || field.name}
- </span>
- </label>
- ))}
- </div>
- </div>
- )}
- {/* Divider between Edit and Detail View settings */}
- <div className="border-t border-gray-200 pt-4">
- <h4 className="mb-4 font-medium text-gray-900">Detail View Settings</h4>
- <p className="mb-4 text-sm text-gray-500">
- Configure how individual items are displayed when clicked (read-only view)
- </p>
- </div>
- {/* Show detail/view button toggle */}
- <label className="flex items-center gap-3">
- <input
- type="checkbox"
- checked={showDetailButton}
- onChange={(e) => setShowDetailButton(e.target.checked)}
- className="h-4 w-4 rounded border-gray-300"
- />
- <div>
- <span className="font-medium text-gray-900">Show view button</span>
- <p className="text-sm text-gray-500">
- Allow users to view item details (read-only) before editing
- </p>
- </div>
- </label>
- {/* Detail view mode selector - only show when detail button is enabled */}
- {showDetailButton && (
- <div>
- <label className="mb-2 block text-sm font-medium text-gray-700">
- Detail view style
- </label>
- <div className="grid gap-3 sm:grid-cols-3">
- <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickDetailMode === 'modal' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
- <input
- type="radio"
- name="quickDetailMode"
- value="modal"
- checked={quickDetailMode === 'modal'}
- onChange={(e) => setQuickDetailMode(e.target.value)}
- className="mt-0.5 h-4 w-4"
- />
- <div>
- <span className="font-medium text-gray-900">Modal</span>
- <p className="text-xs text-gray-500">Popup dialog</p>
- </div>
- </label>
- <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickDetailMode === 'sidebar' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
- <input
- type="radio"
- name="quickDetailMode"
- value="sidebar"
- checked={quickDetailMode === 'sidebar'}
- onChange={(e) => setQuickDetailMode(e.target.value)}
- className="mt-0.5 h-4 w-4"
- />
- <div>
- <span className="font-medium text-gray-900">Sidebar</span>
- <p className="text-xs text-gray-500">Slide-in panel</p>
- </div>
- </label>
- <label className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${quickDetailMode === 'page' ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'}`}>
- <input
- type="radio"
- name="quickDetailMode"
- value="page"
- checked={quickDetailMode === 'page'}
- onChange={(e) => setQuickDetailMode(e.target.value)}
- className="mt-0.5 h-4 w-4"
- />
- <div>
- <span className="font-medium text-gray-900">Full page</span>
- <p className="text-xs text-gray-500">Dedicated page</p>
- </div>
- </label>
- </div>
- <p className="mt-3 text-sm text-gray-500">
- Tip: Configure which fields appear and how they're displayed using the "Detail View Display" options in each field's settings above.
- </p>
- </div>
- )}
- </div>
- </div>
- {/* Permissions Section - only show when editing or when collection is selected */}
- {collectionName && (
- <div className="mt-6 border-t pt-6">
- <h3 className="mb-4 text-lg font-medium text-gray-900">Permissions</h3>
- <ViewPermissionsEditor
- workspaceId={workspaceId}
- collectionName={collectionName}
- fields={sortedFields.map((f) => f.name)}
- />
- </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])
- // Real-time updates: subscribe to view changes
- useRealtimeDocuments({
- workspaceId: currentWorkspace?.id || '',
- collectionName: '_views',
- enabled: !!currentWorkspace?.id,
- onEvent: (event) => {
- // Refetch views when any view changes
- if (event.action === 'create' || event.action === 'update' || event.action === 'delete') {
- 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
|