Views.tsx 65 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628
  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 { useRealtimeDocuments } from '@/hooks/useRealtimeDocuments'
  8. import Button from '@/components/Button'
  9. import Input from '@/components/Input'
  10. import ViewPermissionsEditor from '@/components/ViewPermissionsEditor'
  11. // Field type definitions matching backend
  12. const FIELD_TYPES = [
  13. { value: 'text', label: 'Text', icon: 'T' },
  14. { value: 'number', label: 'Number', icon: '#' },
  15. { value: 'email', label: 'Email', icon: '@' },
  16. { value: 'url', label: 'URL', icon: '🔗' },
  17. { value: 'date', label: 'Date', icon: '📅' },
  18. { value: 'datetime', label: 'Date & Time', icon: '🕐' },
  19. { value: 'boolean', label: 'Boolean', icon: '✓' },
  20. { value: 'select', label: 'Select', icon: '▼' },
  21. { value: 'reference', label: 'Reference', icon: '→' },
  22. { value: 'color', label: 'Color', icon: '🎨' },
  23. ] as const
  24. // Widget types per field type - only show relevant widgets
  25. const WIDGET_TYPES_BY_FIELD: Record<string, Array<{ value: string; label: string }>> = {
  26. text: [
  27. { value: '', label: 'Auto (Single line)' },
  28. { value: 'textarea', label: 'Text Area (Multi-line)' },
  29. ],
  30. number: [
  31. { value: '', label: 'Auto (Number input)' },
  32. { value: 'slider', label: 'Slider' },
  33. ],
  34. email: [
  35. { value: '', label: 'Auto (Email input)' },
  36. ],
  37. url: [
  38. { value: '', label: 'Auto (URL input)' },
  39. ],
  40. date: [
  41. { value: '', label: 'Auto (Date picker)' },
  42. ],
  43. datetime: [
  44. { value: '', label: 'Auto (Date & Time picker)' },
  45. ],
  46. boolean: [
  47. { value: '', label: 'Auto (Checkbox)' },
  48. { value: 'switch', label: 'Toggle Switch' },
  49. ],
  50. select: [
  51. { value: '', label: 'Auto (Dropdown)' },
  52. { value: 'radio', label: 'Radio Buttons' },
  53. ],
  54. reference: [
  55. { value: '', label: 'Auto (Dropdown)' },
  56. { value: 'autocomplete', label: 'Autocomplete' },
  57. ],
  58. color: [
  59. { value: '', label: 'Auto (Color picker)' },
  60. ],
  61. }
  62. // Get widgets for a field type
  63. const getWidgetsForType = (fieldType: string) => {
  64. return WIDGET_TYPES_BY_FIELD[fieldType] || [{ value: '', label: 'Auto' }]
  65. }
  66. // Convert camelCase or PascalCase to Title Case
  67. // e.g., kisCica -> Kis Cica, thisIsAnExample -> This Is An Example
  68. const camelToTitleCase = (str: string): string => {
  69. if (!str) return ''
  70. // Insert space before uppercase letters, then capitalize first letter of each word
  71. return str
  72. .replace(/([A-Z])/g, ' $1') // Add space before uppercase
  73. .replace(/^./, (c) => c.toUpperCase()) // Capitalize first letter
  74. .trim()
  75. }
  76. // Built-in system fields that can be displayed in views
  77. const SYSTEM_FIELDS = [
  78. { name: '_id', label: 'Document ID', type: 'text', description: 'Unique identifier' },
  79. { name: '_created_at', label: 'Created At', type: 'datetime', description: 'Creation timestamp' },
  80. { name: '_updated_at', label: 'Updated At', type: 'datetime', description: 'Last update timestamp' },
  81. { name: '_created_by_name', label: 'Created By', type: 'text', description: 'Name of user who created' },
  82. { name: '_updated_by_name', label: 'Updated By', type: 'text', description: 'Name of user who last updated' },
  83. ] as const
  84. interface SchemaField {
  85. _id: string // Client-side ID for React keys
  86. name: string
  87. type: string
  88. label: string
  89. description: string
  90. required: boolean
  91. display_order: number
  92. widget: string
  93. group: string
  94. default_value?: unknown
  95. options?: string[]
  96. reference_collection?: string
  97. markdown?: boolean // Enable markdown editing/rendering for textarea fields
  98. // List view display options
  99. hidden?: boolean // Hide field from list/table views (still visible in edit/create forms)
  100. maxLength?: number // Maximum characters to display in list views (truncate with ellipsis)
  101. // Detail view display options
  102. hidden_in_detail?: boolean // Hide field from single item detail view
  103. detail_widget?: string // Widget to use in detail view (defaults to same as edit widget)
  104. }
  105. interface ViewSchema {
  106. title?: string
  107. description?: string
  108. layout?: unknown
  109. fields?: Omit<SchemaField, '_id'>[] // Full view detail has fields
  110. field_count?: number // List view has field_count
  111. }
  112. interface View {
  113. id: string
  114. name: string
  115. collection_name: string
  116. workspace_id: string
  117. schema: ViewSchema
  118. settings?: Record<string, unknown>
  119. created_at: string
  120. updated_at: string
  121. }
  122. interface Collection {
  123. name: string
  124. schema?: Record<string, unknown>
  125. is_system: boolean
  126. }
  127. // Field editor component
  128. function FieldEditor({
  129. field,
  130. onUpdate,
  131. onDelete,
  132. onMoveUp,
  133. onMoveDown,
  134. isFirst,
  135. isLast,
  136. collections,
  137. }: {
  138. field: SchemaField
  139. onUpdate: (field: SchemaField) => void
  140. onDelete: () => void
  141. onMoveUp: () => void
  142. onMoveDown: () => void
  143. isFirst: boolean
  144. isLast: boolean
  145. collections: Collection[]
  146. }) {
  147. const [isExpanded, setIsExpanded] = useState(false)
  148. const [optionsInput, setOptionsInput] = useState(field.options?.join('\n') || '')
  149. const handleOptionsChange = (value: string) => {
  150. setOptionsInput(value)
  151. const options = value.split('\n').filter(Boolean)
  152. onUpdate({ ...field, options: options.length > 0 ? options : undefined })
  153. }
  154. return (
  155. <div className="rounded-lg border border-gray-200 bg-white">
  156. {/* Field header */}
  157. <div className="flex items-center gap-2 border-b border-gray-100 p-3">
  158. {/* Drag handle placeholder */}
  159. <div className="cursor-move text-gray-400">
  160. <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  161. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8h16M4 16h16" />
  162. </svg>
  163. </div>
  164. {/* Move buttons */}
  165. <div className="flex flex-col gap-0.5">
  166. <button
  167. onClick={onMoveUp}
  168. disabled={isFirst}
  169. className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-30"
  170. title="Move up"
  171. type="button"
  172. >
  173. <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  174. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
  175. </svg>
  176. </button>
  177. <button
  178. onClick={onMoveDown}
  179. disabled={isLast}
  180. className="rounded p-0.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-30"
  181. title="Move down"
  182. type="button"
  183. >
  184. <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  185. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
  186. </svg>
  187. </button>
  188. </div>
  189. {/* Type badge */}
  190. <span className="flex h-6 w-6 items-center justify-center rounded bg-gray-100 text-xs font-medium text-gray-600">
  191. {FIELD_TYPES.find((t) => t.value === field.type)?.icon || '?'}
  192. </span>
  193. {/* Field name and label */}
  194. <div className="flex-1">
  195. <span className="font-mono text-sm font-medium text-gray-900">{field.name}</span>
  196. {field.label && field.label !== field.name && (
  197. <span className="ml-2 text-sm text-gray-500">({field.label})</span>
  198. )}
  199. </div>
  200. {/* Required badge */}
  201. {field.required && (
  202. <span className="rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700">
  203. Required
  204. </span>
  205. )}
  206. {/* Type selector */}
  207. <select
  208. value={field.type}
  209. onChange={(e) => onUpdate({ ...field, type: e.target.value })}
  210. className="rounded border border-gray-200 px-2 py-1 text-sm"
  211. >
  212. {FIELD_TYPES.map((type) => (
  213. <option key={type.value} value={type.value}>
  214. {type.label}
  215. </option>
  216. ))}
  217. </select>
  218. {/* Expand/collapse */}
  219. <button
  220. onClick={() => setIsExpanded(!isExpanded)}
  221. className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
  222. type="button"
  223. >
  224. <svg
  225. className={`h-5 w-5 transition ${isExpanded ? 'rotate-180' : ''}`}
  226. fill="none"
  227. viewBox="0 0 24 24"
  228. stroke="currentColor"
  229. >
  230. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
  231. </svg>
  232. </button>
  233. {/* Delete button */}
  234. <button
  235. onClick={onDelete}
  236. className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
  237. title="Delete field"
  238. type="button"
  239. >
  240. <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  241. <path
  242. strokeLinecap="round"
  243. strokeLinejoin="round"
  244. strokeWidth={2}
  245. 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"
  246. />
  247. </svg>
  248. </button>
  249. </div>
  250. {/* Field properties (expanded) */}
  251. {isExpanded && (
  252. <div className="grid gap-4 p-4 sm:grid-cols-2">
  253. <Input
  254. label="Display Label"
  255. value={field.label}
  256. onChange={(e) => onUpdate({ ...field, label: e.target.value })}
  257. placeholder={field.name}
  258. />
  259. <Input
  260. label="Description"
  261. value={field.description}
  262. onChange={(e) => onUpdate({ ...field, description: e.target.value })}
  263. placeholder="Help text for this field"
  264. />
  265. <div>
  266. <label className="mb-1.5 block text-sm font-medium text-gray-700">Widget</label>
  267. <select
  268. value={field.widget}
  269. onChange={(e) => onUpdate({ ...field, widget: e.target.value, markdown: e.target.value !== 'textarea' ? false : field.markdown })}
  270. className="w-full rounded-lg border border-gray-300 px-3 py-2"
  271. >
  272. {getWidgetsForType(field.type).map((w) => (
  273. <option key={w.value} value={w.value}>
  274. {w.label}
  275. </option>
  276. ))}
  277. </select>
  278. </div>
  279. {/* Markdown option - only visible for textarea widget */}
  280. {field.widget === 'textarea' && (
  281. <div className="flex items-center">
  282. <label className="flex items-center gap-2">
  283. <input
  284. type="checkbox"
  285. checked={field.markdown || false}
  286. onChange={(e) => onUpdate({ ...field, markdown: e.target.checked })}
  287. className="h-4 w-4 rounded border-gray-300"
  288. />
  289. <span className="text-sm text-gray-700">Enable Markdown</span>
  290. </label>
  291. <span className="ml-2 text-xs text-gray-500">(WYSIWYG editor)</span>
  292. </div>
  293. )}
  294. <Input
  295. label="Field Group"
  296. value={field.group}
  297. onChange={(e) => onUpdate({ ...field, group: e.target.value })}
  298. placeholder="e.g., Basic Info, Contact"
  299. />
  300. {field.type === 'select' && (
  301. <div className="sm:col-span-2">
  302. <label className="mb-1.5 block text-sm font-medium text-gray-700">
  303. Options (one per line)
  304. </label>
  305. <textarea
  306. value={optionsInput}
  307. onChange={(e) => handleOptionsChange(e.target.value)}
  308. placeholder="Option 1&#10;Option 2&#10;Option 3"
  309. rows={3}
  310. className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
  311. />
  312. </div>
  313. )}
  314. {field.type === 'reference' && (
  315. <div>
  316. <label className="mb-1.5 block text-sm font-medium text-gray-700">
  317. Reference Collection
  318. </label>
  319. <select
  320. value={field.reference_collection || ''}
  321. onChange={(e) => onUpdate({ ...field, reference_collection: e.target.value })}
  322. className="w-full rounded-lg border border-gray-300 px-3 py-2"
  323. >
  324. <option value="">Select collection...</option>
  325. {collections
  326. .filter((c) => !c.is_system)
  327. .map((c) => (
  328. <option key={c.name} value={c.name}>
  329. {c.name}
  330. </option>
  331. ))}
  332. </select>
  333. </div>
  334. )}
  335. <div className="flex items-center gap-4 sm:col-span-2">
  336. <label className="flex items-center gap-2">
  337. <input
  338. type="checkbox"
  339. checked={field.required}
  340. onChange={(e) => onUpdate({ ...field, required: e.target.checked })}
  341. className="h-4 w-4 rounded border-gray-300"
  342. />
  343. <span className="text-sm text-gray-700">Required field</span>
  344. </label>
  345. </div>
  346. {/* List View Display Options */}
  347. <div className="sm:col-span-2 border-t pt-4 mt-2">
  348. <h4 className="text-sm font-medium text-gray-700 mb-3">List View Display</h4>
  349. <div className="grid gap-4 sm:grid-cols-2">
  350. <label className="flex items-center gap-2">
  351. <input
  352. type="checkbox"
  353. checked={field.hidden || false}
  354. onChange={(e) => onUpdate({ ...field, hidden: e.target.checked })}
  355. className="h-4 w-4 rounded border-gray-300"
  356. />
  357. <div>
  358. <span className="text-sm text-gray-700">Hide in list view</span>
  359. <p className="text-xs text-gray-500">Field won't show in tables/cards but is visible in forms</p>
  360. </div>
  361. </label>
  362. <div>
  363. <label className="mb-1.5 block text-sm font-medium text-gray-700">Max display length</label>
  364. <input
  365. type="number"
  366. value={field.maxLength || ''}
  367. onChange={(e) => onUpdate({ ...field, maxLength: e.target.value ? parseInt(e.target.value) : undefined })}
  368. placeholder="No limit"
  369. min={1}
  370. className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
  371. />
  372. <p className="mt-1 text-xs text-gray-500">Truncate long content with ellipsis</p>
  373. </div>
  374. </div>
  375. </div>
  376. {/* Detail View Display Options */}
  377. <div className="sm:col-span-2 border-t pt-4 mt-2">
  378. <h4 className="text-sm font-medium text-gray-700 mb-3">Detail View Display</h4>
  379. <div className="grid gap-4 sm:grid-cols-2">
  380. <label className="flex items-center gap-2">
  381. <input
  382. type="checkbox"
  383. checked={field.hidden_in_detail || false}
  384. onChange={(e) => onUpdate({ ...field, hidden_in_detail: e.target.checked })}
  385. className="h-4 w-4 rounded border-gray-300"
  386. />
  387. <div>
  388. <span className="text-sm text-gray-700">Hide in detail view</span>
  389. <p className="text-xs text-gray-500">Field won't show when viewing a single item</p>
  390. </div>
  391. </label>
  392. <div>
  393. <label className="mb-1.5 block text-sm font-medium text-gray-700">Detail display style</label>
  394. <select
  395. value={field.detail_widget || ''}
  396. onChange={(e) => onUpdate({ ...field, detail_widget: e.target.value || undefined })}
  397. className="w-full rounded-lg border border-gray-300 px-3 py-2"
  398. >
  399. <option value="">Same as edit widget</option>
  400. <option value="readonly">Read-only text</option>
  401. <option value="badge">Badge/Tag</option>
  402. <option value="link">Clickable link</option>
  403. {(field.type === 'text' || field.type === 'textarea' || field.widget === 'textarea') && (
  404. <option value="markdown">Render as Markdown</option>
  405. )}
  406. {(field.type === 'text' || field.type === 'textarea' || field.widget === 'textarea') && (
  407. <option value="html">Render as HTML</option>
  408. )}
  409. {field.type === 'color' && (
  410. <option value="swatch">Color swatch</option>
  411. )}
  412. </select>
  413. </div>
  414. </div>
  415. </div>
  416. </div>
  417. )}
  418. </div>
  419. )
  420. }
  421. // View form modal
  422. function ViewModal({
  423. view,
  424. collections,
  425. workspaceId,
  426. onClose,
  427. onSave,
  428. }: {
  429. view: View | null
  430. collections: Collection[]
  431. workspaceId: string
  432. onClose: () => void
  433. onSave: () => void
  434. }) {
  435. const isEditing = !!view
  436. const [name, setName] = useState(view?.name || '')
  437. const [collectionName, setCollectionName] = useState(view?.collection_name || '')
  438. const [fields, setFields] = useState<SchemaField[]>(() => {
  439. if (view?.schema?.fields) {
  440. // Filter out system fields (starting with _) - they are managed separately
  441. return view.schema.fields
  442. .filter(f => !f.name.startsWith('_'))
  443. .map((f, index) => ({
  444. _id: crypto.randomUUID(),
  445. name: f.name,
  446. type: f.type || 'text',
  447. label: f.label || '',
  448. description: f.description || '',
  449. required: f.required || false,
  450. display_order: f.display_order ?? index,
  451. widget: f.widget || '',
  452. group: f.group || '',
  453. default_value: f.default_value,
  454. options: f.options,
  455. reference_collection: f.reference_collection,
  456. markdown: f.markdown,
  457. // List view display options
  458. hidden: f.hidden,
  459. maxLength: f.maxLength,
  460. // Detail view display options
  461. hidden_in_detail: f.hidden_in_detail,
  462. detail_widget: f.detail_widget,
  463. }))
  464. }
  465. return []
  466. })
  467. const [isLoading, setIsLoading] = useState(false)
  468. const [error, setError] = useState<string | null>(null)
  469. // Create settings state
  470. const [showCreateButton, setShowCreateButton] = useState<boolean>(
  471. view?.settings?.show_create_button !== false
  472. )
  473. const [quickCreateMode, setQuickCreateMode] = useState<string>(
  474. (view?.settings?.quick_create_mode as string) || 'modal'
  475. )
  476. const [quickCreateFields, setQuickCreateFields] = useState<string[]>(
  477. (view?.settings?.quick_create_fields as string[]) || []
  478. )
  479. // Edit settings state
  480. const [showEditButton, setShowEditButton] = useState<boolean>(
  481. view?.settings?.show_edit_button !== false
  482. )
  483. const [quickEditMode, setQuickEditMode] = useState<string>(
  484. (view?.settings?.quick_edit_mode as string) || 'modal'
  485. )
  486. const [quickEditFields, setQuickEditFields] = useState<string[]>(
  487. (view?.settings?.quick_edit_fields as string[]) || []
  488. )
  489. // Detail view settings state
  490. const [showDetailButton, setShowDetailButton] = useState<boolean>(
  491. view?.settings?.show_detail_button === true
  492. )
  493. const [quickDetailMode, setQuickDetailMode] = useState<string>(
  494. (view?.settings?.quick_detail_mode as string) || 'modal'
  495. )
  496. // System fields state - built-in document metadata fields
  497. const [enabledSystemFields, setEnabledSystemFields] = useState<string[]>(() => {
  498. if (view?.schema?.fields) {
  499. // Check which system fields are already in the schema (use Set to avoid duplicates)
  500. const systemFieldNames = ['_id', '_created_at', '_updated_at', '_created_by_name', '_updated_by_name']
  501. const enabledSet = new Set<string>()
  502. view.schema.fields.forEach(f => {
  503. if (systemFieldNames.includes(f.name)) {
  504. enabledSet.add(f.name)
  505. }
  506. })
  507. return Array.from(enabledSet)
  508. }
  509. return []
  510. })
  511. // Local state for field name editing to avoid re-renders
  512. const [editingFieldId, setEditingFieldId] = useState<string | null>(null)
  513. const [editingFieldName, setEditingFieldName] = useState('')
  514. const sortedFields = useMemo(
  515. () => [...fields].sort((a, b) => a.display_order - b.display_order),
  516. [fields]
  517. )
  518. const addField = () => {
  519. const newName = `field_${fields.length + 1}`
  520. setFields([
  521. ...fields,
  522. {
  523. _id: crypto.randomUUID(),
  524. name: newName,
  525. type: 'text',
  526. label: '',
  527. description: '',
  528. required: false,
  529. display_order: fields.length,
  530. widget: '',
  531. group: '',
  532. },
  533. ])
  534. }
  535. const updateField = (id: string, updated: SchemaField) => {
  536. setFields(fields.map((f) => (f._id === id ? updated : f)))
  537. }
  538. const deleteField = (id: string) => {
  539. setFields(fields.filter((f) => f._id !== id))
  540. }
  541. const moveField = (index: number, direction: 'up' | 'down') => {
  542. const field = sortedFields[index]
  543. const swapWith = sortedFields[direction === 'up' ? index - 1 : index + 1]
  544. if (!swapWith) return
  545. const fieldOrder = field.display_order
  546. const swapOrder = swapWith.display_order
  547. setFields(
  548. fields.map((f) => {
  549. if (f._id === field._id) return { ...f, display_order: swapOrder }
  550. if (f._id === swapWith._id) return { ...f, display_order: fieldOrder }
  551. return f
  552. })
  553. )
  554. }
  555. const moveSystemField = (index: number, direction: 'up' | 'down') => {
  556. const newIndex = direction === 'up' ? index - 1 : index + 1
  557. if (newIndex < 0 || newIndex >= enabledSystemFields.length) return
  558. const newOrder = [...enabledSystemFields]
  559. const [removed] = newOrder.splice(index, 1)
  560. newOrder.splice(newIndex, 0, removed)
  561. setEnabledSystemFields(newOrder)
  562. }
  563. const startEditingFieldName = (field: SchemaField) => {
  564. setEditingFieldId(field._id)
  565. setEditingFieldName(field.name)
  566. }
  567. const finishEditingFieldName = () => {
  568. if (editingFieldId && editingFieldName.trim()) {
  569. const sanitized = editingFieldName.replace(/[^a-zA-Z0-9_]/g, '')
  570. // Prevent field names starting with _ (reserved for system fields)
  571. if (sanitized.startsWith('_')) {
  572. setError('Field names starting with "_" are reserved for system fields')
  573. setEditingFieldId(null)
  574. setEditingFieldName('')
  575. return
  576. }
  577. if (sanitized && !fields.some((f) => f._id !== editingFieldId && f.name === sanitized)) {
  578. setFields(
  579. fields.map((f) => {
  580. if (f._id === editingFieldId) {
  581. // Auto-fill label from field name if label is empty
  582. const newLabel = f.label || camelToTitleCase(sanitized)
  583. return { ...f, name: sanitized, label: newLabel }
  584. }
  585. return f
  586. })
  587. )
  588. }
  589. }
  590. setEditingFieldId(null)
  591. setEditingFieldName('')
  592. }
  593. const handleSubmit = async (e: React.FormEvent) => {
  594. e.preventDefault()
  595. if (!name.trim()) {
  596. setError('View name is required')
  597. return
  598. }
  599. if (!collectionName) {
  600. setError('Please select a collection')
  601. return
  602. }
  603. setIsLoading(true)
  604. setError(null)
  605. try {
  606. // Build schema in backend format - custom fields
  607. const customFields = sortedFields.map((f) => {
  608. const field: Omit<SchemaField, '_id'> = {
  609. name: f.name,
  610. type: f.type,
  611. label: f.label || f.name,
  612. description: f.description,
  613. required: f.required,
  614. display_order: f.display_order,
  615. widget: f.widget,
  616. group: f.group,
  617. }
  618. if (f.default_value !== undefined) {
  619. field.default_value = f.default_value
  620. }
  621. if (f.options && f.options.length > 0) {
  622. field.options = f.options
  623. }
  624. if (f.reference_collection) {
  625. field.reference_collection = f.reference_collection
  626. }
  627. if (f.markdown) {
  628. field.markdown = f.markdown
  629. }
  630. // List view display options
  631. if (f.hidden) {
  632. field.hidden = f.hidden
  633. }
  634. if (f.maxLength) {
  635. field.maxLength = f.maxLength
  636. }
  637. // Detail view display options
  638. if (f.hidden_in_detail) {
  639. field.hidden_in_detail = f.hidden_in_detail
  640. }
  641. if (f.detail_widget) {
  642. field.detail_widget = f.detail_widget
  643. }
  644. return field
  645. })
  646. // Add enabled system fields (at the end with high display_order)
  647. const systemFieldsToInclude = enabledSystemFields.map((fieldName, index) => {
  648. const systemField = SYSTEM_FIELDS.find(sf => sf.name === fieldName)
  649. return {
  650. name: fieldName,
  651. type: systemField?.type || 'text',
  652. label: systemField?.label || fieldName,
  653. description: systemField?.description || '',
  654. required: false,
  655. display_order: 1000 + index, // Put system fields at the end
  656. widget: '',
  657. group: '_system',
  658. }
  659. })
  660. const schemaFields = [...customFields, ...systemFieldsToInclude]
  661. const payload = {
  662. name,
  663. collection_name: collectionName,
  664. schema: {
  665. fields: schemaFields,
  666. },
  667. settings: {
  668. show_create_button: showCreateButton,
  669. quick_create_mode: quickCreateMode,
  670. quick_create_fields: quickCreateFields.filter(f =>
  671. schemaFields.some(sf => sf.name === f)
  672. ),
  673. show_edit_button: showEditButton,
  674. quick_edit_mode: quickEditMode,
  675. quick_edit_fields: quickEditFields.filter(f =>
  676. schemaFields.some(sf => sf.name === f)
  677. ),
  678. // Detail view settings
  679. show_detail_button: showDetailButton,
  680. quick_detail_mode: quickDetailMode,
  681. },
  682. }
  683. if (isEditing && view) {
  684. await apiClient.request(`/workspaces/${workspaceId}/views/${view.id}`, {
  685. method: 'PATCH',
  686. body: payload,
  687. })
  688. } else {
  689. await apiClient.post(`/workspaces/${workspaceId}/views`, payload)
  690. }
  691. onSave()
  692. } catch (err) {
  693. setError(err instanceof Error ? err.message : 'Failed to save view')
  694. } finally {
  695. setIsLoading(false)
  696. }
  697. }
  698. return (
  699. <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/50 p-4">
  700. <div className="max-h-[95vh] w-full max-w-4xl overflow-y-auto rounded-lg bg-white shadow-xl">
  701. <div className="sticky top-0 z-10 flex items-center justify-between border-b bg-white px-6 py-4">
  702. <h2 className="text-xl font-semibold text-gray-900">
  703. {isEditing ? 'Edit View' : 'Create View'}
  704. </h2>
  705. <button onClick={onClose} className="rounded p-1 text-gray-400 hover:text-gray-600">
  706. <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  707. <path
  708. strokeLinecap="round"
  709. strokeLinejoin="round"
  710. strokeWidth={2}
  711. d="M6 18L18 6M6 6l12 12"
  712. />
  713. </svg>
  714. </button>
  715. </div>
  716. <form onSubmit={handleSubmit}>
  717. <div className="p-6">
  718. {error && (
  719. <div className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
  720. )}
  721. <div className="grid gap-4 sm:grid-cols-2">
  722. <Input
  723. label="View Name"
  724. value={name}
  725. onChange={(e) => setName(e.target.value)}
  726. placeholder="e.g., Customer List"
  727. required
  728. />
  729. <div>
  730. <label className="mb-1.5 block text-sm font-medium text-gray-700">
  731. Collection
  732. <span className="ml-1 text-red-500">*</span>
  733. </label>
  734. <select
  735. value={collectionName}
  736. onChange={(e) => setCollectionName(e.target.value)}
  737. className="w-full rounded-lg border border-gray-300 px-3 py-2"
  738. required
  739. disabled={isEditing}
  740. >
  741. <option value="">Select a collection...</option>
  742. {collections
  743. .filter((c) => !c.is_system)
  744. .map((c) => (
  745. <option key={c.name} value={c.name}>
  746. {c.name}
  747. </option>
  748. ))}
  749. </select>
  750. </div>
  751. </div>
  752. {/* System Fields */}
  753. <div className="mt-6">
  754. <h3 className="mb-3 text-lg font-medium text-gray-900">System Fields</h3>
  755. <p className="mb-3 text-sm text-gray-500">
  756. Include built-in document metadata fields in your view
  757. </p>
  758. <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
  759. {SYSTEM_FIELDS.map((sysField) => (
  760. <label
  761. key={sysField.name}
  762. className={`flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition ${
  763. enabledSystemFields.includes(sysField.name)
  764. ? 'border-primary-500 bg-primary-50'
  765. : 'border-gray-200 hover:border-gray-300'
  766. }`}
  767. >
  768. <input
  769. type="checkbox"
  770. checked={enabledSystemFields.includes(sysField.name)}
  771. onChange={(e) => {
  772. if (e.target.checked) {
  773. setEnabledSystemFields([...enabledSystemFields, sysField.name])
  774. } else {
  775. setEnabledSystemFields(enabledSystemFields.filter(f => f !== sysField.name))
  776. }
  777. }}
  778. className="mt-0.5 h-4 w-4 rounded border-gray-300"
  779. />
  780. <div>
  781. <span className="font-medium text-gray-900">{sysField.label}</span>
  782. <p className="text-xs text-gray-500">{sysField.description}</p>
  783. </div>
  784. </label>
  785. ))}
  786. </div>
  787. {/* Enabled system fields order */}
  788. {enabledSystemFields.length >= 1 && (
  789. <div className="mt-4">
  790. <p className="mb-2 text-sm font-medium text-gray-700">
  791. {enabledSystemFields.length === 1 ? 'Selected Field' : 'Display Order'}
  792. </p>
  793. <div className="space-y-2">
  794. {enabledSystemFields.map((fieldName, index) => {
  795. const sysField = SYSTEM_FIELDS.find(sf => sf.name === fieldName)
  796. if (!sysField) return null
  797. return (
  798. <div
  799. key={fieldName}
  800. className="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2"
  801. >
  802. <div className="flex flex-col">
  803. <button
  804. type="button"
  805. onClick={() => moveSystemField(index, 'up')}
  806. disabled={index === 0}
  807. className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
  808. title="Move up"
  809. >
  810. <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  811. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
  812. </svg>
  813. </button>
  814. <button
  815. type="button"
  816. onClick={() => moveSystemField(index, 'down')}
  817. disabled={index === enabledSystemFields.length - 1}
  818. className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
  819. title="Move down"
  820. >
  821. <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  822. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
  823. </svg>
  824. </button>
  825. </div>
  826. <span className="text-sm font-medium text-gray-700">{sysField.label}</span>
  827. </div>
  828. )
  829. })}
  830. </div>
  831. </div>
  832. )}
  833. </div>
  834. {/* Schema Designer */}
  835. <div className="mt-6">
  836. <div className="mb-3 flex items-center justify-between">
  837. <h3 className="text-lg font-medium text-gray-900">Custom Fields</h3>
  838. <Button type="button" size="sm" onClick={addField}>
  839. <svg className="-ml-1 mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  840. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
  841. </svg>
  842. Add Field
  843. </Button>
  844. </div>
  845. {fields.length === 0 ? (
  846. <div className="rounded-lg border-2 border-dashed border-gray-300 p-8 text-center">
  847. <svg
  848. className="mx-auto h-12 w-12 text-gray-400"
  849. fill="none"
  850. viewBox="0 0 24 24"
  851. stroke="currentColor"
  852. >
  853. <path
  854. strokeLinecap="round"
  855. strokeLinejoin="round"
  856. strokeWidth={1}
  857. 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"
  858. />
  859. </svg>
  860. <p className="mt-2 text-gray-500">No fields yet. Click "Add Field" to get started.</p>
  861. </div>
  862. ) : (
  863. <div className="space-y-3">
  864. {sortedFields.map((field, index) => (
  865. <div key={field._id}>
  866. <div className="mb-1 flex items-center gap-2">
  867. <span className="text-xs text-gray-500">Field name:</span>
  868. {editingFieldId === field._id ? (
  869. <input
  870. type="text"
  871. value={editingFieldName}
  872. onChange={(e) => setEditingFieldName(e.target.value)}
  873. onBlur={finishEditingFieldName}
  874. onKeyDown={(e) => {
  875. if (e.key === 'Enter') {
  876. e.preventDefault()
  877. finishEditingFieldName()
  878. }
  879. if (e.key === 'Escape') {
  880. setEditingFieldId(null)
  881. setEditingFieldName('')
  882. }
  883. }}
  884. autoFocus
  885. 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"
  886. />
  887. ) : (
  888. <button
  889. type="button"
  890. onClick={() => startEditingFieldName(field)}
  891. 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"
  892. >
  893. {field.name}
  894. </button>
  895. )}
  896. </div>
  897. <FieldEditor
  898. field={field}
  899. onUpdate={(updated) => updateField(field._id, updated)}
  900. onDelete={() => deleteField(field._id)}
  901. onMoveUp={() => moveField(index, 'up')}
  902. onMoveDown={() => moveField(index, 'down')}
  903. isFirst={index === 0}
  904. isLast={index === sortedFields.length - 1}
  905. collections={collections}
  906. />
  907. </div>
  908. ))}
  909. </div>
  910. )}
  911. </div>
  912. {/* Settings Section */}
  913. <div className="mt-6 border-t pt-6">
  914. <h3 className="mb-4 text-lg font-medium text-gray-900">Display Settings</h3>
  915. <div className="space-y-4">
  916. {/* Show create button toggle */}
  917. <label className="flex items-center gap-3">
  918. <input
  919. type="checkbox"
  920. checked={showCreateButton}
  921. onChange={(e) => setShowCreateButton(e.target.checked)}
  922. className="h-4 w-4 rounded border-gray-300"
  923. />
  924. <div>
  925. <span className="font-medium text-gray-900">Show create button</span>
  926. <p className="text-sm text-gray-500">
  927. Allow users to add new items directly from this view
  928. </p>
  929. </div>
  930. </label>
  931. {/* Quick create mode selector - only show when create button is enabled */}
  932. {showCreateButton && (
  933. <div>
  934. <label className="mb-2 block text-sm font-medium text-gray-700">
  935. Create form style
  936. </label>
  937. <div className="grid gap-3 sm:grid-cols-3">
  938. <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'}`}>
  939. <input
  940. type="radio"
  941. name="quickCreateMode"
  942. value="modal"
  943. checked={quickCreateMode === 'modal'}
  944. onChange={(e) => setQuickCreateMode(e.target.value)}
  945. className="mt-0.5 h-4 w-4"
  946. />
  947. <div>
  948. <span className="font-medium text-gray-900">Modal</span>
  949. <p className="text-xs text-gray-500">Popup dialog</p>
  950. </div>
  951. </label>
  952. <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'}`}>
  953. <input
  954. type="radio"
  955. name="quickCreateMode"
  956. value="inline"
  957. checked={quickCreateMode === 'inline'}
  958. onChange={(e) => setQuickCreateMode(e.target.value)}
  959. className="mt-0.5 h-4 w-4"
  960. />
  961. <div>
  962. <span className="font-medium text-gray-900">Inline</span>
  963. <p className="text-xs text-gray-500">Form on page</p>
  964. </div>
  965. </label>
  966. <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'}`}>
  967. <input
  968. type="radio"
  969. name="quickCreateMode"
  970. value="page"
  971. checked={quickCreateMode === 'page'}
  972. onChange={(e) => setQuickCreateMode(e.target.value)}
  973. className="mt-0.5 h-4 w-4"
  974. />
  975. <div>
  976. <span className="font-medium text-gray-900">Full page</span>
  977. <p className="text-xs text-gray-500">Dedicated page</p>
  978. </div>
  979. </label>
  980. </div>
  981. </div>
  982. )}
  983. {/* Quick create fields - only show when create button is enabled and mode is not page */}
  984. {showCreateButton && quickCreateMode !== 'page' && sortedFields.length > 0 && (
  985. <div>
  986. <label className="mb-2 block text-sm font-medium text-gray-700">
  987. Quick create fields
  988. </label>
  989. <p className="mb-3 text-sm text-gray-500">
  990. Select which fields to show in the {quickCreateMode === 'inline' ? 'inline form' : 'modal'}. Leave empty to show all fields.
  991. </p>
  992. <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
  993. {sortedFields.filter(f => !f.name.startsWith('_')).map((field) => (
  994. <label key={field._id} className="flex items-center gap-2">
  995. <input
  996. type="checkbox"
  997. checked={quickCreateFields.includes(field.name)}
  998. onChange={(e) => {
  999. if (e.target.checked) {
  1000. setQuickCreateFields([...quickCreateFields, field.name])
  1001. } else {
  1002. setQuickCreateFields(quickCreateFields.filter(f => f !== field.name))
  1003. }
  1004. }}
  1005. className="h-4 w-4 rounded border-gray-300"
  1006. />
  1007. <span className="text-sm text-gray-700">
  1008. {field.label || field.name}
  1009. </span>
  1010. </label>
  1011. ))}
  1012. </div>
  1013. </div>
  1014. )}
  1015. {/* Divider between Create and Edit settings */}
  1016. <div className="border-t border-gray-200 pt-4">
  1017. <h4 className="mb-4 font-medium text-gray-900">Edit Settings</h4>
  1018. </div>
  1019. {/* Show edit button toggle */}
  1020. <label className="flex items-center gap-3">
  1021. <input
  1022. type="checkbox"
  1023. checked={showEditButton}
  1024. onChange={(e) => setShowEditButton(e.target.checked)}
  1025. className="h-4 w-4 rounded border-gray-300"
  1026. />
  1027. <div>
  1028. <span className="font-medium text-gray-900">Show edit button</span>
  1029. <p className="text-sm text-gray-500">
  1030. Allow users to edit items from this view
  1031. </p>
  1032. </div>
  1033. </label>
  1034. {/* Quick edit mode selector - only show when edit button is enabled */}
  1035. {showEditButton && (
  1036. <div>
  1037. <label className="mb-2 block text-sm font-medium text-gray-700">
  1038. Edit form style
  1039. </label>
  1040. <div className="grid gap-3 sm:grid-cols-3">
  1041. <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'}`}>
  1042. <input
  1043. type="radio"
  1044. name="quickEditMode"
  1045. value="modal"
  1046. checked={quickEditMode === 'modal'}
  1047. onChange={(e) => setQuickEditMode(e.target.value)}
  1048. className="mt-0.5 h-4 w-4"
  1049. />
  1050. <div>
  1051. <span className="font-medium text-gray-900">Modal</span>
  1052. <p className="text-xs text-gray-500">Popup dialog</p>
  1053. </div>
  1054. </label>
  1055. <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'}`}>
  1056. <input
  1057. type="radio"
  1058. name="quickEditMode"
  1059. value="inline"
  1060. checked={quickEditMode === 'inline'}
  1061. onChange={(e) => setQuickEditMode(e.target.value)}
  1062. className="mt-0.5 h-4 w-4"
  1063. />
  1064. <div>
  1065. <span className="font-medium text-gray-900">Inline</span>
  1066. <p className="text-xs text-gray-500">Edit in place</p>
  1067. </div>
  1068. </label>
  1069. <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'}`}>
  1070. <input
  1071. type="radio"
  1072. name="quickEditMode"
  1073. value="page"
  1074. checked={quickEditMode === 'page'}
  1075. onChange={(e) => setQuickEditMode(e.target.value)}
  1076. className="mt-0.5 h-4 w-4"
  1077. />
  1078. <div>
  1079. <span className="font-medium text-gray-900">Full page</span>
  1080. <p className="text-xs text-gray-500">Dedicated page</p>
  1081. </div>
  1082. </label>
  1083. </div>
  1084. </div>
  1085. )}
  1086. {/* Quick edit fields - only show when edit button is enabled and mode is not page */}
  1087. {showEditButton && quickEditMode !== 'page' && sortedFields.length > 0 && (
  1088. <div>
  1089. <label className="mb-2 block text-sm font-medium text-gray-700">
  1090. Quick edit fields
  1091. </label>
  1092. <p className="mb-3 text-sm text-gray-500">
  1093. Select which fields to show in the {quickEditMode === 'inline' ? 'inline form' : 'modal'}. Leave empty to show all fields.
  1094. </p>
  1095. <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
  1096. {sortedFields.filter(f => !f.name.startsWith('_')).map((field) => (
  1097. <label key={field._id} className="flex items-center gap-2">
  1098. <input
  1099. type="checkbox"
  1100. checked={quickEditFields.includes(field.name)}
  1101. onChange={(e) => {
  1102. if (e.target.checked) {
  1103. setQuickEditFields([...quickEditFields, field.name])
  1104. } else {
  1105. setQuickEditFields(quickEditFields.filter(f => f !== field.name))
  1106. }
  1107. }}
  1108. className="h-4 w-4 rounded border-gray-300"
  1109. />
  1110. <span className="text-sm text-gray-700">
  1111. {field.label || field.name}
  1112. </span>
  1113. </label>
  1114. ))}
  1115. </div>
  1116. </div>
  1117. )}
  1118. {/* Divider between Edit and Detail View settings */}
  1119. <div className="border-t border-gray-200 pt-4">
  1120. <h4 className="mb-4 font-medium text-gray-900">Detail View Settings</h4>
  1121. <p className="mb-4 text-sm text-gray-500">
  1122. Configure how individual items are displayed when clicked (read-only view)
  1123. </p>
  1124. </div>
  1125. {/* Show detail/view button toggle */}
  1126. <label className="flex items-center gap-3">
  1127. <input
  1128. type="checkbox"
  1129. checked={showDetailButton}
  1130. onChange={(e) => setShowDetailButton(e.target.checked)}
  1131. className="h-4 w-4 rounded border-gray-300"
  1132. />
  1133. <div>
  1134. <span className="font-medium text-gray-900">Show view button</span>
  1135. <p className="text-sm text-gray-500">
  1136. Allow users to view item details (read-only) before editing
  1137. </p>
  1138. </div>
  1139. </label>
  1140. {/* Detail view mode selector - only show when detail button is enabled */}
  1141. {showDetailButton && (
  1142. <div>
  1143. <label className="mb-2 block text-sm font-medium text-gray-700">
  1144. Detail view style
  1145. </label>
  1146. <div className="grid gap-3 sm:grid-cols-3">
  1147. <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'}`}>
  1148. <input
  1149. type="radio"
  1150. name="quickDetailMode"
  1151. value="modal"
  1152. checked={quickDetailMode === 'modal'}
  1153. onChange={(e) => setQuickDetailMode(e.target.value)}
  1154. className="mt-0.5 h-4 w-4"
  1155. />
  1156. <div>
  1157. <span className="font-medium text-gray-900">Modal</span>
  1158. <p className="text-xs text-gray-500">Popup dialog</p>
  1159. </div>
  1160. </label>
  1161. <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'}`}>
  1162. <input
  1163. type="radio"
  1164. name="quickDetailMode"
  1165. value="sidebar"
  1166. checked={quickDetailMode === 'sidebar'}
  1167. onChange={(e) => setQuickDetailMode(e.target.value)}
  1168. className="mt-0.5 h-4 w-4"
  1169. />
  1170. <div>
  1171. <span className="font-medium text-gray-900">Sidebar</span>
  1172. <p className="text-xs text-gray-500">Slide-in panel</p>
  1173. </div>
  1174. </label>
  1175. <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'}`}>
  1176. <input
  1177. type="radio"
  1178. name="quickDetailMode"
  1179. value="page"
  1180. checked={quickDetailMode === 'page'}
  1181. onChange={(e) => setQuickDetailMode(e.target.value)}
  1182. className="mt-0.5 h-4 w-4"
  1183. />
  1184. <div>
  1185. <span className="font-medium text-gray-900">Full page</span>
  1186. <p className="text-xs text-gray-500">Dedicated page</p>
  1187. </div>
  1188. </label>
  1189. </div>
  1190. <p className="mt-3 text-sm text-gray-500">
  1191. Tip: Configure which fields appear and how they're displayed using the "Detail View Display" options in each field's settings above.
  1192. </p>
  1193. </div>
  1194. )}
  1195. </div>
  1196. </div>
  1197. {/* Permissions Section - only show when editing or when collection is selected */}
  1198. {collectionName && (
  1199. <div className="mt-6 border-t pt-6">
  1200. <h3 className="mb-4 text-lg font-medium text-gray-900">Permissions</h3>
  1201. <ViewPermissionsEditor
  1202. workspaceId={workspaceId}
  1203. collectionName={collectionName}
  1204. fields={sortedFields.map((f) => f.name)}
  1205. />
  1206. </div>
  1207. )}
  1208. </div>
  1209. <div className="sticky bottom-0 flex justify-end gap-3 border-t bg-gray-50 px-6 py-4">
  1210. <Button type="button" variant="secondary" onClick={onClose} disabled={isLoading}>
  1211. Cancel
  1212. </Button>
  1213. <Button type="submit" isLoading={isLoading}>
  1214. {isEditing ? 'Save Changes' : 'Create View'}
  1215. </Button>
  1216. </div>
  1217. </form>
  1218. </div>
  1219. </div>
  1220. )
  1221. }
  1222. // Main Views page component
  1223. function Views() {
  1224. const { collectionName } = useParams<{ collectionName?: string }>()
  1225. const navigate = useNavigate()
  1226. const { currentWorkspace } = useWorkspace()
  1227. const [views, setViews] = useState<View[]>([])
  1228. const [collections, setCollections] = useState<Collection[]>([])
  1229. const [isLoading, setIsLoading] = useState(true)
  1230. const [error, setError] = useState<string | null>(null)
  1231. const [searchQuery, setSearchQuery] = useState('')
  1232. // Modals
  1233. const [showCreateModal, setShowCreateModal] = useState(false)
  1234. const [editingView, setEditingView] = useState<View | null>(null)
  1235. const [deletingView, setDeletingView] = useState<View | null>(null)
  1236. const [isLoadingViewDetails, setIsLoadingViewDetails] = useState(false)
  1237. // Fetch full view details before editing
  1238. const loadViewForEditing = useCallback(async (viewId: string) => {
  1239. if (!currentWorkspace) return
  1240. setIsLoadingViewDetails(true)
  1241. try {
  1242. const fullView = await apiClient.get<View>(
  1243. `/workspaces/${currentWorkspace.id}/views/${viewId}`
  1244. )
  1245. setEditingView(fullView)
  1246. } catch (err) {
  1247. setError(err instanceof Error ? err.message : 'Failed to load view details')
  1248. } finally {
  1249. setIsLoadingViewDetails(false)
  1250. }
  1251. }, [currentWorkspace])
  1252. // Fetch collections for the dropdown
  1253. const fetchCollections = useCallback(async () => {
  1254. if (!currentWorkspace) return
  1255. try {
  1256. const response = await apiClient.get<{ collections: Collection[] }>(
  1257. `/workspaces/${currentWorkspace.id}/collections`
  1258. )
  1259. setCollections(response.collections || [])
  1260. } catch (err) {
  1261. console.error('Failed to fetch collections:', err)
  1262. }
  1263. }, [currentWorkspace])
  1264. // Fetch views
  1265. const fetchViews = useCallback(async () => {
  1266. if (!currentWorkspace) {
  1267. setViews([])
  1268. setIsLoading(false)
  1269. return
  1270. }
  1271. setIsLoading(true)
  1272. setError(null)
  1273. try {
  1274. let url = `/workspaces/${currentWorkspace.id}/views`
  1275. if (collectionName) {
  1276. url += `?collection=${encodeURIComponent(collectionName)}`
  1277. }
  1278. const response = await apiClient.get<{ views: View[] }>(url)
  1279. setViews(response.views || [])
  1280. } catch (err) {
  1281. setError(err instanceof Error ? err.message : 'Failed to fetch views')
  1282. } finally {
  1283. setIsLoading(false)
  1284. }
  1285. }, [currentWorkspace, collectionName])
  1286. useEffect(() => {
  1287. fetchCollections()
  1288. }, [fetchCollections])
  1289. useEffect(() => {
  1290. fetchViews()
  1291. }, [fetchViews])
  1292. // Real-time updates: subscribe to view changes
  1293. useRealtimeDocuments({
  1294. workspaceId: currentWorkspace?.id || '',
  1295. collectionName: '_views',
  1296. enabled: !!currentWorkspace?.id,
  1297. onEvent: (event) => {
  1298. // Refetch views when any view changes
  1299. if (event.action === 'create' || event.action === 'update' || event.action === 'delete') {
  1300. fetchViews()
  1301. }
  1302. },
  1303. })
  1304. const filteredViews = useMemo(() => {
  1305. const query = searchQuery.toLowerCase()
  1306. return views.filter(
  1307. (v) => v.name.toLowerCase().includes(query) || v.collection_name.toLowerCase().includes(query)
  1308. )
  1309. }, [views, searchQuery])
  1310. const handleDelete = async () => {
  1311. if (!deletingView || !currentWorkspace) return
  1312. try {
  1313. await apiClient.delete(`/workspaces/${currentWorkspace.id}/views/${deletingView.id}`)
  1314. setDeletingView(null)
  1315. fetchViews()
  1316. } catch (err) {
  1317. setError(err instanceof Error ? err.message : 'Failed to delete view')
  1318. }
  1319. }
  1320. const handleSave = () => {
  1321. setShowCreateModal(false)
  1322. setEditingView(null)
  1323. fetchViews()
  1324. }
  1325. if (!currentWorkspace) {
  1326. return (
  1327. <div className="space-y-6">
  1328. <div>
  1329. <h1 className="text-2xl font-bold text-gray-900">Views</h1>
  1330. <p className="mt-1 text-gray-600">Design custom views for your collections</p>
  1331. </div>
  1332. <div className="rounded-lg bg-yellow-50 p-6 text-center">
  1333. <p className="text-yellow-800">Please select a workspace first.</p>
  1334. </div>
  1335. </div>
  1336. )
  1337. }
  1338. return (
  1339. <div className="space-y-6">
  1340. {/* Header */}
  1341. <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
  1342. <div>
  1343. <div className="flex items-center gap-2">
  1344. {collectionName && (
  1345. <button onClick={() => navigate('/views')} className="text-gray-400 hover:text-gray-600">
  1346. <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  1347. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
  1348. </svg>
  1349. </button>
  1350. )}
  1351. <h1 className="text-2xl font-bold text-gray-900">Views</h1>
  1352. </div>
  1353. <p className="mt-1 text-gray-600">
  1354. {collectionName
  1355. ? `Views for ${collectionName}`
  1356. : `Design custom views in ${currentWorkspace.name}`}
  1357. </p>
  1358. </div>
  1359. <Button onClick={() => setShowCreateModal(true)}>
  1360. <svg className="-ml-1 mr-2 h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  1361. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
  1362. </svg>
  1363. Create View
  1364. </Button>
  1365. </div>
  1366. {/* Search */}
  1367. <div className="max-w-md">
  1368. <Input
  1369. type="search"
  1370. placeholder="Search views..."
  1371. value={searchQuery}
  1372. onChange={(e) => setSearchQuery(e.target.value)}
  1373. />
  1374. </div>
  1375. {/* Error */}
  1376. {error && <div className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
  1377. {/* Loading */}
  1378. {isLoading && (
  1379. <div className="flex items-center justify-center py-12">
  1380. <svg
  1381. className="h-8 w-8 animate-spin text-primary-600"
  1382. xmlns="http://www.w3.org/2000/svg"
  1383. fill="none"
  1384. viewBox="0 0 24 24"
  1385. >
  1386. <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
  1387. <path
  1388. className="opacity-75"
  1389. fill="currentColor"
  1390. 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"
  1391. />
  1392. </svg>
  1393. </div>
  1394. )}
  1395. {/* Views grid */}
  1396. {!isLoading && !error && (
  1397. <div>
  1398. {filteredViews.length === 0 ? (
  1399. <div className="rounded-lg border-2 border-dashed border-gray-300 p-12 text-center">
  1400. <svg
  1401. className="mx-auto h-12 w-12 text-gray-400"
  1402. fill="none"
  1403. viewBox="0 0 24 24"
  1404. stroke="currentColor"
  1405. >
  1406. <path
  1407. strokeLinecap="round"
  1408. strokeLinejoin="round"
  1409. strokeWidth={1}
  1410. 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"
  1411. />
  1412. </svg>
  1413. <p className="mt-4 text-gray-500">{searchQuery ? 'No views match your search' : 'No views yet'}</p>
  1414. {!searchQuery && (
  1415. <Button className="mt-4" onClick={() => setShowCreateModal(true)}>
  1416. Create Your First View
  1417. </Button>
  1418. )}
  1419. </div>
  1420. ) : (
  1421. <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
  1422. {filteredViews.map((view) => (
  1423. <div
  1424. key={view.id}
  1425. className="group relative rounded-lg border border-gray-200 bg-white p-4 transition hover:border-primary-300 hover:shadow-md"
  1426. >
  1427. <div className="flex items-start justify-between">
  1428. <div>
  1429. <h3 className="font-medium text-gray-900">{view.name}</h3>
  1430. <p className="mt-1 text-sm text-gray-500">Collection: {view.collection_name}</p>
  1431. <p className="mt-1 text-xs text-gray-400">
  1432. {view.schema?.fields?.length ?? view.schema?.field_count ?? 0} fields
  1433. </p>
  1434. </div>
  1435. <div className="flex gap-1 opacity-0 transition group-hover:opacity-100">
  1436. <button
  1437. onClick={() => loadViewForEditing(view.id)}
  1438. className="rounded p-1 text-gray-400 hover:bg-primary-50 hover:text-primary-600"
  1439. title="Edit"
  1440. disabled={isLoadingViewDetails}
  1441. >
  1442. <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  1443. <path
  1444. strokeLinecap="round"
  1445. strokeLinejoin="round"
  1446. strokeWidth={2}
  1447. 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"
  1448. />
  1449. </svg>
  1450. </button>
  1451. <button
  1452. onClick={() => setDeletingView(view)}
  1453. className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-600"
  1454. title="Delete"
  1455. >
  1456. <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  1457. <path
  1458. strokeLinecap="round"
  1459. strokeLinejoin="round"
  1460. strokeWidth={2}
  1461. 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"
  1462. />
  1463. </svg>
  1464. </button>
  1465. </div>
  1466. </div>
  1467. <div className="mt-3 flex flex-wrap gap-1">
  1468. {(view.schema?.fields || []).slice(0, 4).map((field) => (
  1469. <span key={field.name} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
  1470. {field.name}
  1471. </span>
  1472. ))}
  1473. {(view.schema?.fields?.length || 0) > 4 && (
  1474. <span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
  1475. +{(view.schema?.fields?.length || 0) - 4} more
  1476. </span>
  1477. )}
  1478. </div>
  1479. </div>
  1480. ))}
  1481. </div>
  1482. )}
  1483. </div>
  1484. )}
  1485. {/* Modals */}
  1486. {(showCreateModal || editingView) && (
  1487. <ViewModal
  1488. key={editingView?.id || 'new'}
  1489. view={editingView}
  1490. collections={collections}
  1491. workspaceId={currentWorkspace.id}
  1492. onClose={() => {
  1493. setShowCreateModal(false)
  1494. setEditingView(null)
  1495. }}
  1496. onSave={handleSave}
  1497. />
  1498. )}
  1499. {deletingView && (
  1500. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
  1501. <div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
  1502. <h2 className="text-xl font-semibold text-gray-900">Delete View</h2>
  1503. <p className="mt-2 text-gray-600">
  1504. Are you sure you want to delete the view "{deletingView.name}"? This action cannot be undone.
  1505. </p>
  1506. <div className="mt-6 flex justify-end gap-3">
  1507. <Button variant="secondary" onClick={() => setDeletingView(null)}>
  1508. Cancel
  1509. </Button>
  1510. <Button variant="danger" onClick={handleDelete}>
  1511. Delete View
  1512. </Button>
  1513. </div>
  1514. </div>
  1515. </div>
  1516. )}
  1517. </div>
  1518. )
  1519. }
  1520. export default Views