Views.tsx 36 KB

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