Views.tsx 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939
  1. // View/Schema Designer page (US-033)
  2. // Visual schema designer for creating custom views
  3. import { useState, useEffect, useCallback, useMemo } from 'react'
  4. import { useParams, useNavigate } from 'react-router-dom'
  5. import apiClient from '@/api/client'
  6. import { useWorkspace } from '@/contexts/WorkspaceContext'
  7. import Button from '@/components/Button'
  8. import Input from '@/components/Input'
  9. // Field type definitions matching backend
  10. const FIELD_TYPES = [
  11. { value: 'text', label: 'Text', icon: 'T' },
  12. { value: 'number', label: 'Number', icon: '#' },
  13. { value: 'email', label: 'Email', icon: '@' },
  14. { value: 'url', label: 'URL', icon: '🔗' },
  15. { value: 'date', label: 'Date', icon: '📅' },
  16. { value: 'datetime', label: 'Date & Time', icon: '🕐' },
  17. { value: 'boolean', label: 'Boolean', icon: '✓' },
  18. { value: 'select', label: 'Select', icon: '▼' },
  19. { value: 'reference', label: 'Reference', icon: '→' },
  20. ] as const
  21. const WIDGET_TYPES = [
  22. { value: '', label: 'Auto' },
  23. { value: 'textarea', label: 'Text Area' },
  24. { value: 'select', label: 'Dropdown' },
  25. { value: 'radio', label: 'Radio Buttons' },
  26. { value: 'checkbox', label: 'Checkbox' },
  27. { value: 'color', label: 'Color Picker' },
  28. ] as const
  29. interface SchemaField {
  30. _id: string // Client-side ID for React keys
  31. name: string
  32. type: string
  33. label: string
  34. description: string
  35. required: boolean
  36. display_order: number
  37. widget: string
  38. group: string
  39. default_value?: unknown
  40. options?: string[]
  41. reference_collection?: string
  42. }
  43. interface ViewSchema {
  44. title?: string
  45. description?: string
  46. layout?: unknown
  47. fields?: Omit<SchemaField, '_id'>[] // Full view detail has fields
  48. field_count?: number // List view has field_count
  49. }
  50. interface View {
  51. id: string
  52. name: string
  53. collection_name: string
  54. workspace_id: string
  55. schema: ViewSchema
  56. settings?: Record<string, unknown>
  57. created_at: string
  58. updated_at: string
  59. }
  60. interface Collection {
  61. name: string
  62. schema?: Record<string, unknown>
  63. is_system: boolean
  64. }
  65. // Field editor component
  66. function FieldEditor({
  67. field,
  68. onUpdate,
  69. onDelete,
  70. onMoveUp,
  71. onMoveDown,
  72. isFirst,
  73. isLast,
  74. collections,
  75. }: {
  76. field: SchemaField
  77. onUpdate: (field: SchemaField) => void
  78. onDelete: () => void
  79. onMoveUp: () => void
  80. onMoveDown: () => void
  81. isFirst: boolean
  82. isLast: boolean
  83. collections: Collection[]
  84. }) {
  85. const [isExpanded, setIsExpanded] = useState(false)
  86. const [optionsInput, setOptionsInput] = useState(field.options?.join('\n') || '')
  87. const handleOptionsChange = (value: string) => {
  88. setOptionsInput(value)
  89. const options = value.split('\n').filter(Boolean)
  90. onUpdate({ ...field, options: options.length > 0 ? options : undefined })
  91. }
  92. return (
  93. <div className="rounded-lg border border-gray-200 bg-white">
  94. {/* Field header */}
  95. <div className="flex items-center gap-2 border-b border-gray-100 p-3">
  96. {/* Drag handle placeholder */}
  97. <div className="cursor-move text-gray-400">
  98. <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  99. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
  100. </svg>
  101. </div>
  102. {/* Move buttons */}
  103. <div className="flex flex-col gap-0.5">
  104. <button
  105. onClick={onMoveUp}
  106. disabled={isFirst}
  107. className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-30"
  108. title="Move up"
  109. type="button"
  110. >
  111. <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  112. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
  113. </svg>
  114. </button>
  115. <button
  116. onClick={onMoveDown}
  117. disabled={isLast}
  118. className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-30"
  119. title="Move down"
  120. type="button"
  121. >
  122. <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  123. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
  124. </svg>
  125. </button>
  126. </div>
  127. {/* Type badge */}
  128. <span className="flex h-6 w-6 items-center justify-center rounded bg-gray-100 text-xs font-medium text-gray-600">
  129. {FIELD_TYPES.find((t) => t.value === field.type)?.icon || '?'}
  130. </span>
  131. {/* Field name and label */}
  132. <div className="flex-1">
  133. <span className="font-mono text-sm font-medium text-gray-900">{field.name}</span>
  134. {field.label && field.label !== field.name && (
  135. <span className="ml-2 text-sm text-gray-500">({field.label})</span>
  136. )}
  137. </div>
  138. {/* Required badge */}
  139. {field.required && (
  140. <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700">
  141. Required
  142. </span>
  143. )}
  144. {/* Type selector */}
  145. <select
  146. value={field.type}
  147. onChange={(e) => onUpdate({ ...field, type: e.target.value })}
  148. className="rounded border border-gray-200 px-2 py-1 text-sm"
  149. >
  150. {FIELD_TYPES.map((type) => (
  151. <option key={type.value} value={type.value}>
  152. {type.label}
  153. </option>
  154. ))}
  155. </select>
  156. {/* Expand/collapse */}
  157. <button
  158. onClick={() => setIsExpanded(!isExpanded)}
  159. className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
  160. type="button"
  161. >
  162. <svg
  163. className={`h-5 w-5 transition ${isExpanded ? 'rotate-180' : ''}`}
  164. fill="none"
  165. viewBox="0 0 24 24"
  166. stroke="currentColor"
  167. >
  168. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
  169. </svg>
  170. </button>
  171. {/* Delete button */}
  172. <button
  173. onClick={onDelete}
  174. className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
  175. title="Delete field"
  176. type="button"
  177. >
  178. <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  179. <path
  180. strokeLinecap="round"
  181. strokeLinejoin="round"
  182. strokeWidth={2}
  183. 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"
  184. />
  185. </svg>
  186. </button>
  187. </div>
  188. {/* Field properties (expanded) */}
  189. {isExpanded && (
  190. <div className="grid gap-4 p-4 sm:grid-cols-2">
  191. <Input
  192. label="Display Label"
  193. value={field.label}
  194. onChange={(e) => onUpdate({ ...field, label: e.target.value })}
  195. placeholder={field.name}
  196. />
  197. <Input
  198. label="Description"
  199. value={field.description}
  200. onChange={(e) => onUpdate({ ...field, description: e.target.value })}
  201. placeholder="Help text for this field"
  202. />
  203. <div>
  204. <label className="mb-1.5 block text-sm font-medium text-gray-700">Widget</label>
  205. <select
  206. value={field.widget}
  207. onChange={(e) => onUpdate({ ...field, widget: e.target.value })}
  208. className="w-full rounded-lg border border-gray-300 px-3 py-2"
  209. >
  210. {WIDGET_TYPES.map((w) => (
  211. <option key={w.value} value={w.value}>
  212. {w.label}
  213. </option>
  214. ))}
  215. </select>
  216. </div>
  217. <Input
  218. label="Field Group"
  219. value={field.group}
  220. onChange={(e) => onUpdate({ ...field, group: e.target.value })}
  221. placeholder="e.g., Basic Info, Contact"
  222. />
  223. {field.type === 'select' && (
  224. <div className="sm:col-span-2">
  225. <label className="mb-1.5 block text-sm font-medium text-gray-700">
  226. Options (one per line)
  227. </label>
  228. <textarea
  229. value={optionsInput}
  230. onChange={(e) => handleOptionsChange(e.target.value)}
  231. placeholder="Option 1&#10;Option 2&#10;Option 3"
  232. rows={3}
  233. className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
  234. />
  235. </div>
  236. )}
  237. {field.type === 'reference' && (
  238. <div>
  239. <label className="mb-1.5 block text-sm font-medium text-gray-700">
  240. Reference Collection
  241. </label>
  242. <select
  243. value={field.reference_collection || ''}
  244. onChange={(e) => onUpdate({ ...field, reference_collection: e.target.value })}
  245. className="w-full rounded-lg border border-gray-300 px-3 py-2"
  246. >
  247. <option value="">Select collection...</option>
  248. {collections
  249. .filter((c) => !c.is_system)
  250. .map((c) => (
  251. <option key={c.name} value={c.name}>
  252. {c.name}
  253. </option>
  254. ))}
  255. </select>
  256. </div>
  257. )}
  258. <div className="flex items-center gap-4 sm:col-span-2">
  259. <label className="flex items-center gap-2">
  260. <input
  261. type="checkbox"
  262. checked={field.required}
  263. onChange={(e) => onUpdate({ ...field, required: e.target.checked })}
  264. className="h-4 w-4 rounded border-gray-300"
  265. />
  266. <span className="text-sm text-gray-700">Required field</span>
  267. </label>
  268. </div>
  269. </div>
  270. )}
  271. </div>
  272. )
  273. }
  274. // View form modal
  275. function ViewModal({
  276. view,
  277. collections,
  278. workspaceId,
  279. onClose,
  280. onSave,
  281. }: {
  282. view: View | null
  283. collections: Collection[]
  284. workspaceId: string
  285. onClose: () => void
  286. onSave: () => void
  287. }) {
  288. const isEditing = !!view
  289. const [name, setName] = useState(view?.name || '')
  290. const [collectionName, setCollectionName] = useState(view?.collection_name || '')
  291. const [fields, setFields] = useState<SchemaField[]>(() => {
  292. if (view?.schema?.fields) {
  293. return view.schema.fields.map((f, index) => ({
  294. _id: crypto.randomUUID(),
  295. name: f.name,
  296. type: f.type || 'text',
  297. label: f.label || '',
  298. description: f.description || '',
  299. required: f.required || false,
  300. display_order: f.display_order ?? index,
  301. widget: f.widget || '',
  302. group: f.group || '',
  303. default_value: f.default_value,
  304. options: f.options,
  305. reference_collection: f.reference_collection,
  306. }))
  307. }
  308. return []
  309. })
  310. const [isLoading, setIsLoading] = useState(false)
  311. const [error, setError] = useState<string | null>(null)
  312. // Local state for field name editing to avoid re-renders
  313. const [editingFieldId, setEditingFieldId] = useState<string | null>(null)
  314. const [editingFieldName, setEditingFieldName] = useState('')
  315. const sortedFields = useMemo(
  316. () => [...fields].sort((a, b) => a.display_order - b.display_order),
  317. [fields]
  318. )
  319. const addField = () => {
  320. const newName = `field_${fields.length + 1}`
  321. setFields([
  322. ...fields,
  323. {
  324. _id: crypto.randomUUID(),
  325. name: newName,
  326. type: 'text',
  327. label: '',
  328. description: '',
  329. required: false,
  330. display_order: fields.length,
  331. widget: '',
  332. group: '',
  333. },
  334. ])
  335. }
  336. const updateField = (id: string, updated: SchemaField) => {
  337. setFields(fields.map((f) => (f._id === id ? updated : f)))
  338. }
  339. const deleteField = (id: string) => {
  340. setFields(fields.filter((f) => f._id !== id))
  341. }
  342. const moveField = (index: number, direction: 'up' | 'down') => {
  343. const field = sortedFields[index]
  344. const swapWith = sortedFields[direction === 'up' ? index - 1 : index + 1]
  345. if (!swapWith) return
  346. const fieldOrder = field.display_order
  347. const swapOrder = swapWith.display_order
  348. setFields(
  349. fields.map((f) => {
  350. if (f._id === field._id) return { ...f, display_order: swapOrder }
  351. if (f._id === swapWith._id) return { ...f, display_order: fieldOrder }
  352. return f
  353. })
  354. )
  355. }
  356. const startEditingFieldName = (field: SchemaField) => {
  357. setEditingFieldId(field._id)
  358. setEditingFieldName(field.name)
  359. }
  360. const finishEditingFieldName = () => {
  361. if (editingFieldId && editingFieldName.trim()) {
  362. const sanitized = editingFieldName.replace(/[^a-zA-Z0-9_]/g, '')
  363. if (sanitized && !fields.some((f) => f._id !== editingFieldId && f.name === sanitized)) {
  364. setFields(
  365. fields.map((f) => (f._id === editingFieldId ? { ...f, name: sanitized } : f))
  366. )
  367. }
  368. }
  369. setEditingFieldId(null)
  370. setEditingFieldName('')
  371. }
  372. const handleSubmit = async (e: React.FormEvent) => {
  373. e.preventDefault()
  374. if (!name.trim()) {
  375. setError('View name is required')
  376. return
  377. }
  378. if (!collectionName) {
  379. setError('Please select a collection')
  380. return
  381. }
  382. setIsLoading(true)
  383. setError(null)
  384. try {
  385. // Build schema in backend format
  386. const schemaFields = sortedFields.map((f) => {
  387. const field: Omit<SchemaField, '_id'> = {
  388. name: f.name,
  389. type: f.type,
  390. label: f.label || f.name,
  391. description: f.description,
  392. required: f.required,
  393. display_order: f.display_order,
  394. widget: f.widget,
  395. group: f.group,
  396. }
  397. if (f.default_value !== undefined) {
  398. field.default_value = f.default_value
  399. }
  400. if (f.options && f.options.length > 0) {
  401. field.options = f.options
  402. }
  403. if (f.reference_collection) {
  404. field.reference_collection = f.reference_collection
  405. }
  406. return field
  407. })
  408. const payload = {
  409. name,
  410. collection_name: collectionName,
  411. schema: {
  412. fields: schemaFields,
  413. },
  414. }
  415. if (isEditing && view) {
  416. await apiClient.request(`/workspaces/${workspaceId}/views/${view.id}`, {
  417. method: 'PATCH',
  418. body: payload,
  419. })
  420. } else {
  421. await apiClient.post(`/workspaces/${workspaceId}/views`, payload)
  422. }
  423. onSave()
  424. } catch (err) {
  425. setError(err instanceof Error ? err.message : 'Failed to save view')
  426. } finally {
  427. setIsLoading(false)
  428. }
  429. }
  430. return (
  431. <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4">
  432. <div className="max-h-[95vh] w-full max-w-4xl overflow-y-auto rounded-lg bg-white shadow-xl">
  433. <div className="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-6 py-4">
  434. <h2 className="text-xl font-semibold text-gray-900">
  435. {isEditing ? 'Edit View' : 'Create View'}
  436. </h2>
  437. <button onClick={onClose} className="rounded p-1 text-gray-400 hover:text-gray-600">
  438. <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  439. <path
  440. strokeLinecap="round"
  441. strokeLinejoin="round"
  442. strokeWidth={2}
  443. d="M6 18L18 6M6 6l12 12"
  444. />
  445. </svg>
  446. </button>
  447. </div>
  448. <form onSubmit={handleSubmit}>
  449. <div className="p-6">
  450. {error && (
  451. <div className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
  452. )}
  453. <div className="grid gap-4 sm:grid-cols-2">
  454. <Input
  455. label="View Name"
  456. value={name}
  457. onChange={(e) => setName(e.target.value)}
  458. placeholder="e.g., Customer List"
  459. required
  460. />
  461. <div>
  462. <label className="mb-1.5 block text-sm font-medium text-gray-700">
  463. Collection
  464. <span className="ml-1 text-red-500">*</span>
  465. </label>
  466. <select
  467. value={collectionName}
  468. onChange={(e) => setCollectionName(e.target.value)}
  469. className="w-full rounded-lg border border-gray-300 px-3 py-2"
  470. required
  471. disabled={isEditing}
  472. >
  473. <option value="">Select a collection...</option>
  474. {collections
  475. .filter((c) => !c.is_system)
  476. .map((c) => (
  477. <option key={c.name} value={c.name}>
  478. {c.name}
  479. </option>
  480. ))}
  481. </select>
  482. </div>
  483. </div>
  484. {/* Schema Designer */}
  485. <div className="mt-6">
  486. <div className="mb-3 flex items-center justify-between">
  487. <h3 className="text-lg font-medium text-gray-900">Fields</h3>
  488. <Button type="button" size="sm" onClick={addField}>
  489. <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  490. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
  491. </svg>
  492. Add Field
  493. </Button>
  494. </div>
  495. {fields.length === 0 ? (
  496. <div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
  497. <svg
  498. className="mx-auto h-12 w-12 text-gray-400"
  499. fill="none"
  500. viewBox="0 0 24 24"
  501. stroke="currentColor"
  502. >
  503. <path
  504. strokeLinecap="round"
  505. strokeLinejoin="round"
  506. strokeWidth={1}
  507. 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"
  508. />
  509. </svg>
  510. <p className="mt-2 text-gray-500">No fields yet. Click "Add Field" to get started.</p>
  511. </div>
  512. ) : (
  513. <div className="space-y-3">
  514. {sortedFields.map((field, index) => (
  515. <div key={field._id}>
  516. <div className="mb-1 flex items-center gap-2">
  517. <span className="text-xs text-gray-500">Field name:</span>
  518. {editingFieldId === field._id ? (
  519. <input
  520. type="text"
  521. value={editingFieldName}
  522. onChange={(e) => setEditingFieldName(e.target.value)}
  523. onBlur={finishEditingFieldName}
  524. onKeyDown={(e) => {
  525. if (e.key === 'Enter') {
  526. e.preventDefault()
  527. finishEditingFieldName()
  528. }
  529. if (e.key === 'Escape') {
  530. setEditingFieldId(null)
  531. setEditingFieldName('')
  532. }
  533. }}
  534. autoFocus
  535. 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"
  536. />
  537. ) : (
  538. <button
  539. type="button"
  540. onClick={() => startEditingFieldName(field)}
  541. 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"
  542. >
  543. {field.name}
  544. </button>
  545. )}
  546. </div>
  547. <FieldEditor
  548. field={field}
  549. onUpdate={(updated) => updateField(field._id, updated)}
  550. onDelete={() => deleteField(field._id)}
  551. onMoveUp={() => moveField(index, 'up')}
  552. onMoveDown={() => moveField(index, 'down')}
  553. isFirst={index === 0}
  554. isLast={index === sortedFields.length - 1}
  555. collections={collections}
  556. />
  557. </div>
  558. ))}
  559. </div>
  560. )}
  561. </div>
  562. </div>
  563. <div className="sticky bottom-0 flex justify-end gap-3 border-t bg-gray-50 px-6 py-4">
  564. <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
  565. Cancel
  566. </Button>
  567. <Button type="submit" isLoading={isLoading}>
  568. {isEditing ? 'Save Changes' : 'Create View'}
  569. </Button>
  570. </div>
  571. </form>
  572. </div>
  573. </div>
  574. )
  575. }
  576. // Main Views page component
  577. function Views() {
  578. const { collectionName } = useParams<{ collectionName?: string }>()
  579. const navigate = useNavigate()
  580. const { currentWorkspace } = useWorkspace()
  581. const [views, setViews] = useState<View[]>([])
  582. const [collections, setCollections] = useState<Collection[]>([])
  583. const [isLoading, setIsLoading] = useState(true)
  584. const [error, setError] = useState<string | null>(null)
  585. const [searchQuery, setSearchQuery] = useState('')
  586. // Modals
  587. const [showCreateModal, setShowCreateModal] = useState(false)
  588. const [editingView, setEditingView] = useState<View | null>(null)
  589. const [deletingView, setDeletingView] = useState<View | null>(null)
  590. const [isLoadingViewDetails, setIsLoadingViewDetails] = useState(false)
  591. // Fetch full view details before editing
  592. const loadViewForEditing = useCallback(async (viewId: string) => {
  593. if (!currentWorkspace) return
  594. setIsLoadingViewDetails(true)
  595. try {
  596. const fullView = await apiClient.get<View>(
  597. `/workspaces/${currentWorkspace.id}/views/${viewId}`
  598. )
  599. setEditingView(fullView)
  600. } catch (err) {
  601. setError(err instanceof Error ? err.message : 'Failed to load view details')
  602. } finally {
  603. setIsLoadingViewDetails(false)
  604. }
  605. }, [currentWorkspace])
  606. // Fetch collections for the dropdown
  607. const fetchCollections = useCallback(async () => {
  608. if (!currentWorkspace) return
  609. try {
  610. const response = await apiClient.get<{ collections: Collection[] }>(
  611. `/workspaces/${currentWorkspace.id}/collections`
  612. )
  613. setCollections(response.collections || [])
  614. } catch (err) {
  615. console.error('Failed to fetch collections:', err)
  616. }
  617. }, [currentWorkspace])
  618. // Fetch views
  619. const fetchViews = useCallback(async () => {
  620. if (!currentWorkspace) {
  621. setViews([])
  622. setIsLoading(false)
  623. return
  624. }
  625. setIsLoading(true)
  626. setError(null)
  627. try {
  628. let url = `/workspaces/${currentWorkspace.id}/views`
  629. if (collectionName) {
  630. url += `?collection=${encodeURIComponent(collectionName)}`
  631. }
  632. const response = await apiClient.get<{ views: View[] }>(url)
  633. setViews(response.views || [])
  634. } catch (err) {
  635. setError(err instanceof Error ? err.message : 'Failed to fetch views')
  636. } finally {
  637. setIsLoading(false)
  638. }
  639. }, [currentWorkspace, collectionName])
  640. useEffect(() => {
  641. fetchCollections()
  642. }, [fetchCollections])
  643. useEffect(() => {
  644. fetchViews()
  645. }, [fetchViews])
  646. const filteredViews = useMemo(() => {
  647. const query = searchQuery.toLowerCase()
  648. return views.filter(
  649. (v) => v.name.toLowerCase().includes(query) || v.collection_name.toLowerCase().includes(query)
  650. )
  651. }, [views, searchQuery])
  652. const handleDelete = async () => {
  653. if (!deletingView || !currentWorkspace) return
  654. try {
  655. await apiClient.delete(`/workspaces/${currentWorkspace.id}/views/${deletingView.id}`)
  656. setDeletingView(null)
  657. fetchViews()
  658. } catch (err) {
  659. setError(err instanceof Error ? err.message : 'Failed to delete view')
  660. }
  661. }
  662. const handleSave = () => {
  663. setShowCreateModal(false)
  664. setEditingView(null)
  665. fetchViews()
  666. }
  667. if (!currentWorkspace) {
  668. return (
  669. <div className="space-y-6">
  670. <div>
  671. <h1 className="text-2xl font-bold text-gray-900">Views</h1>
  672. <p className="mt-1 text-gray-600">Design custom views for your collections</p>
  673. </div>
  674. <div className="rounded-lg bg-yellow-50 p-6 text-center">
  675. <p className="text-yellow-800">Please select a workspace first.</p>
  676. </div>
  677. </div>
  678. )
  679. }
  680. return (
  681. <div className="space-y-6">
  682. {/* Header */}
  683. <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
  684. <div>
  685. <div className="flex items-center gap-2">
  686. {collectionName && (
  687. <button onClick={() => navigate('/views')} className="text-gray-400 hover:text-gray-600">
  688. <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  689. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
  690. </svg>
  691. </button>
  692. )}
  693. <h1 className="text-2xl font-bold text-gray-900">Views</h1>
  694. </div>
  695. <p className="mt-1 text-gray-600">
  696. {collectionName
  697. ? `Views for ${collectionName}`
  698. : `Design custom views in ${currentWorkspace.name}`}
  699. </p>
  700. </div>
  701. <Button onClick={() => setShowCreateModal(true)}>
  702. <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  703. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
  704. </svg>
  705. Create View
  706. </Button>
  707. </div>
  708. {/* Search */}
  709. <div className="max-w-md">
  710. <Input
  711. type="search"
  712. placeholder="Search views..."
  713. value={searchQuery}
  714. onChange={(e) => setSearchQuery(e.target.value)}
  715. />
  716. </div>
  717. {/* Error */}
  718. {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
  719. {/* Loading */}
  720. {isLoading && (
  721. <div className="flex items-center justify-center py-12">
  722. <svg
  723. className="h-8 w-8 animate-spin text-primary-600"
  724. xmlns="http://www.w3.org/2000/svg"
  725. fill="none"
  726. viewBox="0 0 24 24"
  727. >
  728. <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
  729. <path
  730. className="opacity-75"
  731. fill="currentColor"
  732. 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"
  733. />
  734. </svg>
  735. </div>
  736. )}
  737. {/* Views grid */}
  738. {!isLoading && !error && (
  739. <div>
  740. {filteredViews.length === 0 ? (
  741. <div className="rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
  742. <svg
  743. className="mx-auto h-12 w-12 text-gray-400"
  744. fill="none"
  745. viewBox="0 0 24 24"
  746. stroke="currentColor"
  747. >
  748. <path
  749. strokeLinecap="round"
  750. strokeLinejoin="round"
  751. strokeWidth={1}
  752. 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"
  753. />
  754. </svg>
  755. <p className="mt-4 text-gray-500">{searchQuery ? 'No views match your search' : 'No views yet'}</p>
  756. {!searchQuery && (
  757. <Button className="mt-4" onClick={() => setShowCreateModal(true)}>
  758. Create Your First View
  759. </Button>
  760. )}
  761. </div>
  762. ) : (
  763. <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
  764. {filteredViews.map((view) => (
  765. <div
  766. key={view.id}
  767. className="group relative rounded-lg border border-gray-200 bg-white p-4 transition hover:border-primary-300 hover:shadow-md"
  768. >
  769. <div className="flex items-start justify-between">
  770. <div>
  771. <h3 className="font-medium text-gray-900">{view.name}</h3>
  772. <p className="mt-1 text-sm text-gray-500">Collection: {view.collection_name}</p>
  773. <p className="mt-1 text-xs text-gray-400">
  774. {view.schema?.fields?.length ?? view.schema?.field_count ?? 0} fields
  775. </p>
  776. </div>
  777. <div className="flex gap-1 opacity-0 transition group-hover:opacity-100">
  778. <button
  779. onClick={() => loadViewForEditing(view.id)}
  780. className="rounded p-1 text-gray-400 hover:bg-primary-50 hover:text-primary-600"
  781. title="Edit"
  782. disabled={isLoadingViewDetails}
  783. >
  784. <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  785. <path
  786. strokeLinecap="round"
  787. strokeLinejoin="round"
  788. strokeWidth={2}
  789. 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"
  790. />
  791. </svg>
  792. </button>
  793. <button
  794. onClick={() => setDeletingView(view)}
  795. className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
  796. title="Delete"
  797. >
  798. <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  799. <path
  800. strokeLinecap="round"
  801. strokeLinejoin="round"
  802. strokeWidth={2}
  803. 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"
  804. />
  805. </svg>
  806. </button>
  807. </div>
  808. </div>
  809. <div className="mt-3 flex flex-wrap gap-1">
  810. {(view.schema?.fields || []).slice(0, 4).map((field) => (
  811. <span key={field.name} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
  812. {field.name}
  813. </span>
  814. ))}
  815. {(view.schema?.fields?.length || 0) > 4 && (
  816. <span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
  817. +{(view.schema?.fields?.length || 0) - 4} more
  818. </span>
  819. )}
  820. </div>
  821. </div>
  822. ))}
  823. </div>
  824. )}
  825. </div>
  826. )}
  827. {/* Modals */}
  828. {(showCreateModal || editingView) && (
  829. <ViewModal
  830. key={editingView?.id || 'new'}
  831. view={editingView}
  832. collections={collections}
  833. workspaceId={currentWorkspace.id}
  834. onClose={() => {
  835. setShowCreateModal(false)
  836. setEditingView(null)
  837. }}
  838. onSave={handleSave}
  839. />
  840. )}
  841. {deletingView && (
  842. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
  843. <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
  844. <h2 className="text-xl font-semibold text-gray-900">Delete View</h2>
  845. <p className="mt-2 text-gray-600">
  846. Are you sure you want to delete the view "{deletingView.name}"? This action cannot be undone.
  847. </p>
  848. <div className="mt-6 flex justify-end gap-3">
  849. <Button variant="secondary" onClick={() => setDeletingView(null)}>
  850. Cancel
  851. </Button>
  852. <Button variant="danger" onClick={handleDelete}>
  853. Delete View
  854. </Button>
  855. </div>
  856. </div>
  857. </div>
  858. )}
  859. </div>
  860. )
  861. }
  862. export default Views