@nestledjs/data-browser 0.1.1 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/index.d.ts +15 -0
  2. package/index.js +64 -0
  3. package/lib/admin-data.d.ts +2 -0
  4. package/lib/components/filters/DateRangeFilter.d.ts +7 -0
  5. package/lib/components/filters/DateRangeFilter.js +105 -0
  6. package/lib/components/filters/NumberRangeFilter.d.ts +8 -0
  7. package/lib/components/filters/NumberRangeFilter.js +115 -0
  8. package/lib/components/filters/RelationComponents.d.ts +32 -0
  9. package/lib/components/filters/RelationComponents.js +265 -0
  10. package/lib/components/filters/RelationFilterField.d.ts +8 -0
  11. package/lib/components/filters/RelationFilterField.js +116 -0
  12. package/lib/components/filters/index.d.ts +4 -0
  13. package/lib/components/index.d.ts +2 -0
  14. package/lib/components/shared/AdminBreadcrumbs.d.ts +13 -0
  15. package/lib/components/shared/AdminBreadcrumbs.js +103 -0
  16. package/lib/components/shared/AdminErrorStates.d.ts +30 -0
  17. package/lib/components/shared/AdminErrorStates.js +214 -0
  18. package/lib/components/shared/AdminStatusDisplay.d.ts +21 -0
  19. package/lib/components/shared/AdminStatusDisplay.js +205 -0
  20. package/lib/components/shared/index.d.ts +3 -0
  21. package/lib/context/AdminDataContext.d.ts +47 -0
  22. package/lib/context/AdminDataContext.js +28 -0
  23. package/lib/hooks/useAdminList.d.ts +5 -0
  24. package/lib/hooks/useAdminList.js +41 -0
  25. package/lib/hooks/useClickOutside.d.ts +2 -0
  26. package/lib/hooks/useClickOutside.js +13 -0
  27. package/lib/hooks/useDebounce.d.ts +1 -0
  28. package/lib/hooks/useDebounce.js +13 -0
  29. package/lib/hooks/useRelationData.d.ts +7 -0
  30. package/lib/hooks/useRelationData.js +64 -0
  31. package/lib/layouts/AdminDataLayout.d.ts +5 -0
  32. package/lib/layouts/AdminDataLayout.js +315 -0
  33. package/lib/pages/AdminDataCreatePage.d.ts +4 -0
  34. package/lib/pages/AdminDataCreatePage.js +487 -0
  35. package/lib/pages/AdminDataEditPage.d.ts +4 -0
  36. package/lib/pages/AdminDataEditPage.js +903 -0
  37. package/lib/pages/AdminDataIndexPage.d.ts +4 -0
  38. package/lib/pages/AdminDataIndexPage.js +91 -0
  39. package/lib/pages/AdminDataListPage.d.ts +9 -0
  40. package/lib/pages/AdminDataListPage.js +1218 -0
  41. package/lib/types/index.d.ts +60 -0
  42. package/lib/types/index.js +16 -0
  43. package/lib/utils/graphql-utils.d.ts +25 -0
  44. package/lib/utils/graphql-utils.js +265 -0
  45. package/lib/utils/secure-storage.d.ts +50 -0
  46. package/lib/utils/secure-storage.js +145 -0
  47. package/lib/utils/string-utils.d.ts +6 -0
  48. package/lib/utils/string-utils.js +27 -0
  49. package/package.json +16 -3
  50. package/.babelrc +0 -12
  51. package/LICENSE +0 -21
  52. package/eslint.config.mjs +0 -12
  53. package/project.json +0 -35
  54. package/src/index.ts +0 -28
  55. package/src/lib/admin-data.spec.tsx +0 -10
  56. package/src/lib/admin-data.tsx +0 -9
  57. package/src/lib/components/filters/DateRangeFilter.tsx +0 -88
  58. package/src/lib/components/filters/NumberRangeFilter.tsx +0 -111
  59. package/src/lib/components/filters/RelationComponents.tsx +0 -176
  60. package/src/lib/components/filters/RelationFilterField.tsx +0 -106
  61. package/src/lib/components/filters/index.ts +0 -5
  62. package/src/lib/components/index.ts +0 -2
  63. package/src/lib/components/shared/AdminBreadcrumbs.tsx +0 -88
  64. package/src/lib/components/shared/AdminErrorStates.tsx +0 -180
  65. package/src/lib/components/shared/AdminStatusDisplay.tsx +0 -292
  66. package/src/lib/components/shared/index.ts +0 -3
  67. package/src/lib/context/AdminDataContext.tsx +0 -74
  68. package/src/lib/hooks/useAdminList.ts +0 -42
  69. package/src/lib/hooks/useClickOutside.ts +0 -21
  70. package/src/lib/hooks/useDebounce.ts +0 -16
  71. package/src/lib/hooks/useRelationData.ts +0 -114
  72. package/src/lib/layouts/AdminDataLayout.tsx +0 -251
  73. package/src/lib/pages/AdminDataCreatePage.tsx +0 -415
  74. package/src/lib/pages/AdminDataEditPage.tsx +0 -777
  75. package/src/lib/pages/AdminDataIndexPage.tsx +0 -50
  76. package/src/lib/pages/AdminDataListPage.tsx +0 -921
  77. package/src/lib/types/index.ts +0 -51
  78. package/src/lib/utils/graphql-utils.ts +0 -538
  79. package/src/lib/utils/secure-storage.ts +0 -305
  80. package/src/lib/utils/string-utils.ts +0 -53
  81. package/tsconfig.json +0 -17
  82. package/tsconfig.lib.json +0 -20
  83. package/vite.config.ts +0 -35
@@ -1,777 +0,0 @@
1
- import { gql } from '@apollo/client'
2
- import { useMutation, useQuery } from '@apollo/client/react'
3
- import { loadDevMessages, loadErrorMessages } from '@apollo/client/dev'
4
- import { CheckCircleIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline'
5
- import { TrashIcon } from '@heroicons/react/24/solid'
6
- import { useAdminDataContext } from '../context/AdminDataContext'
7
-
8
- function toLowerCamelCase(name: string): string {
9
- if (!name) return ''
10
- return name.charAt(0).toLowerCase() + name.slice(1)
11
- }
12
-
13
- function getModelIdVariableName(modelName: string): string {
14
- return `${toLowerCamelCase(modelName)}Id`
15
- }
16
-
17
- function getModelResponseFieldName(modelName: string): string {
18
- return toLowerCamelCase(modelName)
19
- }
20
-
21
- function toReadableText(text: string): string {
22
- return text.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, str => str.toUpperCase())
23
- }
24
- import { ErrorBoundary } from '@nestledjs/shared-components'
25
- import { Form } from '@nestledjs/forms'
26
- import { useEffect, useMemo, useState } from 'react'
27
- import { Link, useNavigate, useParams } from 'react-router'
28
-
29
- import { buildFormFields, cleanFormInput, getAdminDocuments } from '../utils/graphql-utils' // Load Apollo error messages in development
30
-
31
- // Load Apollo error messages in development
32
- if (process.env.NODE_ENV !== 'production') {
33
- loadDevMessages()
34
- loadErrorMessages()
35
- }
36
-
37
- // Security validation utilities
38
- const sanitizeInput = (input: string | undefined): string => {
39
- if (!input || typeof input !== 'string') return ''
40
-
41
- // Remove potentially dangerous characters and limit length
42
- return input
43
- .replace(/[<>"'%;()&+]/g, '') // Remove common injection characters
44
- .replace(/javascript:/gi, '') // Remove javascript: protocols
45
- .replace(/on\w+\s*=/gi, '') // Remove event handlers
46
- .trim()
47
- .substring(0, 100) // Limit length to prevent DoS
48
- }
49
-
50
- // Convert PascalCase to kebab-case for URLs (CourseChapter -> course-chapter)
51
- const toKebabCase = (str: string): string => {
52
- return str
53
- .replace(/([a-z])([A-Z])/g, '$1-$2') // Insert dash between lowercase and uppercase
54
- .toLowerCase() // Convert to lowercase
55
- }
56
-
57
- // Validation functions moved into component to access databaseModels from context
58
-
59
- const validateId = (id: string | undefined): string | null => {
60
- if (!id) return null
61
-
62
- const sanitized = sanitizeInput(id)
63
- if (!sanitized) return null
64
-
65
- // Basic ID format validation (adjust based on your ID format)
66
- if (!/^[a-zA-Z0-9_-]+$/.test(sanitized)) {
67
- console.warn(`[Security] Invalid ID format attempted: ${id}`)
68
- return null
69
- }
70
-
71
- return sanitized
72
- }
73
-
74
- export function AdminDataEditPage() {
75
- const { dataType, id } = useParams()
76
- const { databaseModels, basePath = '/admin/data', formTheme } = useAdminDataContext()
77
-
78
- // Helper function to find model by name
79
- const findModelByName = (name: string) => {
80
- return databaseModels.find(model => model.name === name)
81
- }
82
-
83
- // Validate data type
84
- const validateDataType = (dataType: string | undefined): string | null => {
85
- if (!dataType) return null
86
-
87
- const sanitized = sanitizeInput(dataType)
88
- if (!sanitized) return null
89
-
90
- // Convert kebab-case to PascalCase (course-chapter -> CourseChapter)
91
- const properCaseDataType = sanitized
92
- .split('-')
93
- .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
94
- .join('')
95
-
96
- // Check if this data type exists in our models
97
- const model = databaseModels.find(m => m.name === properCaseDataType)
98
- if (!model) {
99
- console.warn(`[Security] Invalid dataType attempted: ${dataType}`)
100
- return null
101
- }
102
-
103
- return properCaseDataType
104
- }
105
-
106
- // Security validation
107
- const validatedDataType = validateDataType(dataType)
108
- const validatedId = validateId(id)
109
-
110
- // Determine what to render based on validation
111
- const shouldShowUnauthorized = !validatedDataType || !validatedId
112
- const model = validatedDataType ? findModelByName(validatedDataType) : null
113
- const shouldShowModelNotFound = validatedDataType && validatedId && !model
114
-
115
- // Render error states
116
- if (shouldShowUnauthorized) {
117
- return (
118
- <div className="flex flex-col justify-center py-12">
119
- <div className="mt-8 mx-auto w-full max-w-md">
120
- <div className="bg-white dark:bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
121
- <div className="text-center">
122
- <ExclamationCircleIcon className="mx-auto h-12 w-12 text-red-400" />
123
- <h2 className="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">Unauthorized</h2>
124
- <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
125
- Invalid data type, ID, or insufficient permissions.
126
- </p>
127
- <div className="mt-6">
128
- <Link
129
- to={basePath}
130
- className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-web hover:bg-green-web-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-web"
131
- >
132
- Return to Data Browser
133
- </Link>
134
- </div>
135
- </div>
136
- </div>
137
- </div>
138
- </div>
139
- )
140
- }
141
-
142
- if (shouldShowModelNotFound) {
143
- return (
144
- <div className="flex flex-col justify-center py-12">
145
- <div className="mt-8 mx-auto w-full max-w-md">
146
- <div className="bg-white dark:bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
147
- <div className="text-center">
148
- <ExclamationCircleIcon className="mx-auto h-12 w-12 text-yellow-400" />
149
- <h2 className="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">Model Not Found</h2>
150
- <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
151
- The data model for "{validatedDataType}" could not be found.
152
- </p>
153
- <div className="mt-6">
154
- <Link
155
- to={basePath}
156
- className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-web hover:bg-green-web-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-web"
157
- >
158
- Return to Data Browser
159
- </Link>
160
- </div>
161
- </div>
162
- </div>
163
- </div>
164
- </div>
165
- )
166
- }
167
-
168
- // At this point we know model exists and is valid
169
- return <AdminDataEditPageContent model={model!} id={validatedId!} basePath={basePath} formTheme={formTheme} />
170
- }
171
-
172
- // =================================
173
- // CONTENT COMPONENT
174
- // =================================
175
-
176
- function AdminDataEditPageContent({ model, id, basePath, formTheme }: Readonly<{ model: any; id: string; basePath: string; formTheme: any }>) {
177
- const navigate = useNavigate()
178
- const { sdk } = useAdminDataContext()
179
-
180
- // State
181
- const [submissionState, setSubmissionState] = useState<{
182
- status: 'idle' | 'loading' | 'success' | 'error'
183
- message?: string
184
- }>({ status: 'idle' })
185
-
186
- const [deleteState, setDeleteState] = useState<{
187
- status: 'idle' | 'loading' | 'success' | 'error'
188
- message?: string
189
- }>({ status: 'idle' })
190
-
191
- const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
192
-
193
- // Get GraphQL documents with error handling (memoized to prevent render loops)
194
- const documents = useMemo(() => {
195
- try {
196
- return getAdminDocuments(sdk, model)
197
- } catch (error) {
198
- console.error('[AdminDataEditPage] Error getting documents:', error)
199
- return null
200
- }
201
- }, [sdk, model])
202
-
203
- const QUERY = useMemo(() => {
204
- if (!documents?.query) return null
205
- try {
206
- // Check if it's already a parsed GraphQL document
207
- if (documents.query?.definitions && documents.query?.loc) {
208
- return documents.query
209
- }
210
- return gql(documents.query)
211
- } catch (error) {
212
- console.error('[AdminDataEditPage] Error parsing QUERY:', error)
213
- return null
214
- }
215
- }, [documents])
216
-
217
- const UPDATE_MUTATION = useMemo(() => {
218
- if (!documents?.update) return null
219
- try {
220
- // Check if it's already a parsed GraphQL document
221
- if (documents.update?.definitions && documents.update?.loc) {
222
- return documents.update
223
- }
224
- return gql(documents.update)
225
- } catch (error) {
226
- console.error('[AdminDataEditPage] Error parsing UPDATE mutation:', error)
227
- return null
228
- }
229
- }, [documents])
230
-
231
- const DELETE_MUTATION = useMemo(() => {
232
- if (!documents?.delete) return null
233
- try {
234
- // Check if it's already a parsed GraphQL document
235
- if (documents.delete?.definitions && documents.delete?.loc) {
236
- return documents.delete
237
- }
238
- return gql(documents.delete)
239
- } catch (error) {
240
- console.error('[AdminDataEditPage] Error parsing DELETE mutation:', error)
241
- return null
242
- }
243
- }, [documents])
244
-
245
- // Variables for GraphQL operations
246
- const idVariableName = getModelIdVariableName(model.name)
247
- const responseFieldName = getModelResponseFieldName(model.name)
248
-
249
- // Query for existing data - use skip to prevent running when documents are missing
250
- const { data, loading, error, refetch } = useQuery(
251
- QUERY ||
252
- gql`
253
- query PlaceholderQuery {
254
- __typename
255
- }
256
- `,
257
- {
258
- variables: { [idVariableName]: id },
259
- errorPolicy: 'all',
260
- skip: !QUERY || !id,
261
- },
262
- )
263
-
264
- // Mutations - provide placeholder mutations to prevent hook order issues
265
- const [updateMutation] = useMutation(
266
- UPDATE_MUTATION ||
267
- gql`
268
- mutation PlaceholderUpdate {
269
- __typename
270
- }
271
- `,
272
- )
273
- const [deleteMutation] = useMutation(
274
- DELETE_MUTATION ||
275
- gql`
276
- mutation PlaceholderDelete {
277
- __typename
278
- }
279
- `,
280
- )
281
-
282
- // ALL useEffect hooks MUST be called before any early returns
283
- // Clear submission state after errors
284
- useEffect(() => {
285
- if (submissionState.status === 'error') {
286
- const timer = setTimeout(() => {
287
- setSubmissionState({ status: 'idle' })
288
- }, 5000)
289
- return () => clearTimeout(timer)
290
- }
291
- }, [submissionState.status])
292
-
293
- useEffect(() => {
294
- if (deleteState.status === 'error') {
295
- const timer = setTimeout(() => {
296
- setDeleteState({ status: 'idle' })
297
- }, 5000)
298
- return () => clearTimeout(timer)
299
- }
300
- }, [deleteState.status])
301
-
302
- // Early returns after ALL hooks are called
303
- if (!documents || !QUERY || !UPDATE_MUTATION || !DELETE_MUTATION) {
304
- return (
305
- <div className="flex flex-col justify-center py-12">
306
- <div className="mt-8 mx-auto w-full max-w-2xl">
307
- <div className="bg-white dark:bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
308
- <div className="text-center">
309
- <ExclamationCircleIcon className="mx-auto h-12 w-12 text-red-400" />
310
- <h2 className="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">GraphQL Schema Error</h2>
311
- <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
312
- Unable to load GraphQL documents for this model. Please ensure the API server is
313
- running and the GraphQL schema is up to date.
314
- </p>
315
- <div className="mt-6">
316
- <Link
317
- to={basePath}
318
- className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-web hover:bg-green-web-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-web"
319
- >
320
- Return to Data Browser
321
- </Link>
322
- </div>
323
- </div>
324
- </div>
325
- </div>
326
- </div>
327
- )
328
- }
329
-
330
- // Handle query errors
331
- if (error) {
332
- return (
333
- <div className="flex flex-col justify-center py-12">
334
- <div className="mt-8 mx-auto w-full max-w-md">
335
- <div className="bg-white dark:bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
336
- <div className="text-center">
337
- <ExclamationCircleIcon className="mx-auto h-12 w-12 text-red-400" />
338
- <h2 className="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">Error Loading Data</h2>
339
- <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{error.message}</p>
340
- <div className="mt-6 space-y-3">
341
- <button
342
- onClick={() => refetch()}
343
- className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-web hover:bg-green-web-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-web"
344
- >
345
- Try Again
346
- </button>
347
- <Link
348
- to={`/admin/data/${toKebabCase(model.pluralName)}`}
349
- className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-web"
350
- >
351
- Back to List
352
- </Link>
353
- </div>
354
- </div>
355
- </div>
356
- </div>
357
- </div>
358
- )
359
- }
360
-
361
- // Show loading state
362
- if (loading) {
363
- return (
364
- <div className="flex flex-col justify-center py-12">
365
- <div className="mt-8 mx-auto w-full max-w-md">
366
- <div className="bg-white dark:bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
367
- <div className="text-center">
368
- <div className="mx-auto h-12 w-12 border-4 border-green-web border-t-transparent rounded-full animate-spin" />
369
- <h2 className="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">Loading...</h2>
370
- <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
371
- Loading {toReadableText(model.name)} data...
372
- </p>
373
- </div>
374
- </div>
375
- </div>
376
- </div>
377
- )
378
- }
379
-
380
- // Get the item data
381
- const item = (data as any)?.[responseFieldName]
382
-
383
- if (!item) {
384
- return (
385
- <div className="flex flex-col justify-center py-12">
386
- <div className="mt-8 mx-auto w-full max-w-md">
387
- <div className="bg-white dark:bg-gray-800 py-8 px-4 shadow sm:rounded-lg sm:px-10">
388
- <div className="text-center">
389
- <ExclamationCircleIcon className="mx-auto h-12 w-12 text-yellow-400" />
390
- <h2 className="mt-4 text-lg font-medium text-gray-900 dark:text-gray-100">Not Found</h2>
391
- <p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
392
- The {toReadableText(model.name)} you're looking for doesn't exist or has been
393
- deleted.
394
- </p>
395
- <div className="mt-6">
396
- <Link
397
- to={`/admin/data/${toKebabCase(model.pluralName)}`}
398
- className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-web hover:bg-green-web-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-web"
399
- >
400
- Back to List
401
- </Link>
402
- </div>
403
- </div>
404
- </div>
405
- </div>
406
- </div>
407
- )
408
- }
409
-
410
- // Build form fields without values (we'll use defaultValues prop instead)
411
- const formFields = buildFormFields(sdk, model, 'update')
412
-
413
- // Extract initial values for the Form component
414
- const initialValues: Record<string, any> = {}
415
- if (item) {
416
- // Get editable fields and map their current values
417
- const editableFields = model.fields.filter((field: any) => {
418
- if (field.isId && false) return false // Keep ID for edit
419
- if (field.isReadOnly || field.isGenerated) return false
420
- if (field.isUpdatedAt || field.name === 'createdAt') return false
421
- if (field.relationName && field.isList) return false
422
- return true
423
- })
424
-
425
- editableFields.forEach((field: any) => {
426
- let value = item[field.name]
427
-
428
- // Handle relation fields - get the foreign key value
429
- if (field.relationName && !field.isList) {
430
- const relationFieldName = field.relationFromFields?.[0] || field.name
431
- value = item[relationFieldName]
432
-
433
- // Extract ID from object, or convert to empty string if it's still an object
434
- if (value && typeof value === 'object') {
435
- value = value.id || ''
436
- }
437
-
438
- initialValues[relationFieldName] = value || ''
439
- } else {
440
- // Convert Date objects and timestamps to proper format for date/datetime fields
441
- if (field.type.toLowerCase() === 'datetime' || field.type.toLowerCase() === 'date') {
442
- if (value !== null && value !== undefined && value !== '') {
443
- try {
444
- // Handle Date objects, ISO strings, and timestamps
445
- const dateValue = value instanceof Date ? value : new Date(value)
446
-
447
- if (field.type.toLowerCase() === 'date') {
448
- // Date fields: YYYY-MM-DD format
449
- value = dateValue.toISOString().split('T')[0]
450
- } else {
451
- // DateTime fields: YYYY-MM-DDTHH:mm format (for datetime-local input)
452
- const isoString = dateValue.toISOString()
453
- // Extract YYYY-MM-DDTHH:mm (remove seconds and timezone)
454
- value = isoString.substring(0, 16)
455
- }
456
- } catch (e) {
457
- console.warn(`Failed to convert date value for field ${field.name}:`, e)
458
- value = ''
459
- }
460
- } else {
461
- value = ''
462
- }
463
- }
464
-
465
- // Convert null to empty string for form fields
466
- if (value === null && field.type.toLowerCase() !== 'boolean') {
467
- value = ''
468
- }
469
- // For boolean fields, ensure we have a proper boolean
470
- if (field.type.toLowerCase() === 'boolean') {
471
- value = Boolean(value)
472
- }
473
-
474
- // Safety check: convert any remaining non-primitive values to strings
475
- if (value !== null && typeof value === 'object') {
476
- console.warn(`Field ${field.name} has object value, converting to string:`, value)
477
- // If it's an object with an id, use the id
478
- if (typeof value === 'object' && 'id' in value) {
479
- value = (value as any).id
480
- } else {
481
- value = ''
482
- }
483
- }
484
-
485
- initialValues[field.name] = value
486
- }
487
- })
488
-
489
- // Final safety check: convert undefined and remaining objects
490
- for (const [key, value] of Object.entries(initialValues)) {
491
- // Convert undefined to empty string (form library can't handle undefined)
492
- if (value === undefined) {
493
- console.warn(`[AdminDataEditPage] Found undefined value for field ${key}, converting to empty string`)
494
- initialValues[key] = ''
495
- continue
496
- }
497
-
498
- // Skip null values - forms can handle null
499
- if (value === null) {
500
- continue
501
- }
502
-
503
- // Skip primitives - they're already in the correct format
504
- if (typeof value !== 'object') {
505
- continue
506
- }
507
-
508
- // Convert any remaining objects to primitives
509
- console.warn(`[AdminDataEditPage] Found non-primitive value for field ${key}, converting:`, value)
510
- if ('id' in value && typeof (value as any).id === 'string') {
511
- initialValues[key] = (value as any).id
512
- } else if (value instanceof Date) {
513
- // This shouldn't happen if date conversion above worked, but handle it just in case
514
- const field = model.fields.find((f: any) => f.name === key)
515
- initialValues[key] = field?.type.toLowerCase() === 'date'
516
- ? value.toISOString().split('T')[0]
517
- : value.toISOString()
518
- } else {
519
- // Convert everything else to empty string for safety
520
- initialValues[key] = ''
521
- }
522
- }
523
-
524
- // Debug: Log the final initialValues to see what we're passing to the form
525
- console.log('[AdminDataEditPage] Final initialValues:', initialValues)
526
- console.log('[AdminDataEditPage] Value types:', Object.entries(initialValues).map(([key, val]) => ({
527
- key,
528
- type: typeof val,
529
- isDate: val instanceof Date,
530
- value: val
531
- })))
532
- }
533
-
534
- // Handle form submission
535
- const handleSubmit = async (formData: Record<string, unknown>) => {
536
- try {
537
- setSubmissionState({ status: 'loading' })
538
-
539
- // Clean the form input
540
- const cleanedInput = cleanFormInput(formData, model)
541
-
542
- // Execute mutation
543
- const result = await updateMutation({
544
- variables: {
545
- input: cleanedInput,
546
- [idVariableName]: id,
547
- },
548
- })
549
-
550
- if ((result as any).errors) {
551
- console.error('GraphQL errors:', (result as any).errors)
552
- setSubmissionState({
553
- status: 'error',
554
- message: (result as any).errors.map((err: any) => err.message).join(', '),
555
- })
556
- return
557
- }
558
-
559
- setSubmissionState({
560
- status: 'success',
561
- message: `${toReadableText(model.name)} updated successfully!`,
562
- })
563
-
564
- // Refetch the data to show updated values
565
- await refetch()
566
- } catch (error) {
567
- console.error('Error updating record:', error)
568
- setSubmissionState({
569
- status: 'error',
570
- message: error instanceof Error ? error.message : 'An unexpected error occurred',
571
- })
572
- }
573
- }
574
-
575
- // Handle delete
576
- const handleDelete = async () => {
577
- try {
578
- setDeleteState({ status: 'loading' })
579
-
580
- const result = await deleteMutation({
581
- variables: {
582
- [idVariableName]: id,
583
- },
584
- })
585
-
586
- if ((result as any).errors) {
587
- console.error('GraphQL errors:', (result as any).errors)
588
- setDeleteState({
589
- status: 'error',
590
- message: (result as any).errors.map((err: any) => err.message).join(', '),
591
- })
592
- return
593
- }
594
-
595
- setDeleteState({
596
- status: 'success',
597
- message: `${toReadableText(model.name)} deleted successfully!`,
598
- })
599
-
600
- // Redirect after a brief delay
601
- setTimeout(() => {
602
- navigate(`/admin/data/${toKebabCase(model.pluralName)}`)
603
- }, 1500)
604
- } catch (error) {
605
- console.error('Error deleting record:', error)
606
- setDeleteState({
607
- status: 'error',
608
- message: error instanceof Error ? error.message : 'An unexpected error occurred',
609
- })
610
- }
611
- }
612
-
613
- return (
614
- <div className="space-y-6">
615
- {/* Header */}
616
- <div className="mb-8">
617
- <nav className="flex mb-6" aria-label="Breadcrumb">
618
- <ol className="flex items-center space-x-4">
619
- <li>
620
- <Link to="/admin/data" className="text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400">
621
- Data Browser
622
- </Link>
623
- </li>
624
- <li>
625
- <div className="flex items-center">
626
- <svg
627
- className="flex-shrink-0 h-5 w-5 text-gray-300 dark:text-gray-600"
628
- fill="currentColor"
629
- viewBox="0 0 20 20"
630
- >
631
- <path
632
- fillRule="evenodd"
633
- d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 111.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
634
- clipRule="evenodd"
635
- />
636
- </svg>
637
- <Link
638
- to={`/admin/data/${toKebabCase(model.pluralName)}`}
639
- className="ml-4 text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400"
640
- >
641
- {toReadableText(model.pluralName)}
642
- </Link>
643
- </div>
644
- </li>
645
- <li>
646
- <div className="flex items-center">
647
- <svg
648
- className="flex-shrink-0 h-5 w-5 text-gray-300 dark:text-gray-600"
649
- fill="currentColor"
650
- viewBox="0 0 20 20"
651
- >
652
- <path
653
- fillRule="evenodd"
654
- d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 111.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
655
- clipRule="evenodd"
656
- />
657
- </svg>
658
- <span className="ml-4 text-gray-500 dark:text-gray-400">Edit</span>
659
- </div>
660
- </li>
661
- </ol>
662
- </nav>
663
- <div className="flex items-center justify-between">
664
- <h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">Edit {toReadableText(model.name)}</h1>
665
- <button
666
- onClick={() => setShowDeleteConfirm(true)}
667
- disabled={deleteState.status === 'loading'}
668
- className="inline-flex items-center px-3 py-2 border border-red-300 shadow-sm text-sm leading-4 font-medium rounded-md text-red-700 bg-white hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
669
- >
670
- <TrashIcon className="h-4 w-4 mr-2" />
671
- Delete
672
- </button>
673
- </div>
674
- </div>
675
-
676
- {/* Delete Confirmation Modal */}
677
- {showDeleteConfirm && (
678
- <div className="fixed inset-0 bg-gray-600 dark:bg-gray-900 bg-opacity-50 dark:bg-opacity-75 overflow-y-auto h-full w-full z-50 flex items-center justify-center">
679
- <div className="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl max-w-md w-full mx-4">
680
- <div className="flex items-center">
681
- <div className="flex-shrink-0">
682
- <ExclamationCircleIcon className="h-6 w-6 text-red-600" />
683
- </div>
684
- <div className="ml-3">
685
- <h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">
686
- Delete {toReadableText(model.name)}
687
- </h3>
688
- <div className="mt-2">
689
- <p className="text-sm text-gray-500 dark:text-gray-400">
690
- Are you sure you want to delete this{' '}
691
- {toReadableText(model.name).toLowerCase()}? This action cannot be undone.
692
- </p>
693
- </div>
694
- </div>
695
- </div>
696
- <div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
697
- <button
698
- onClick={handleDelete}
699
- disabled={deleteState.status === 'loading'}
700
- className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
701
- >
702
- {deleteState.status === 'loading' ? 'Deleting...' : 'Delete'}
703
- </button>
704
- <button
705
- onClick={() => setShowDeleteConfirm(false)}
706
- disabled={deleteState.status === 'loading'}
707
- className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-web sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
708
- >
709
- Cancel
710
- </button>
711
- </div>
712
- </div>
713
- </div>
714
- )}
715
-
716
- {/* Submission Status */}
717
- {(submissionState.status !== 'idle' || deleteState.status !== 'idle') && (
718
- <div
719
- className={`mb-6 rounded-md p-4 ${
720
- submissionState.status === 'success' || deleteState.status === 'success'
721
- ? 'bg-green-50 border border-green-200'
722
- : submissionState.status === 'error' || deleteState.status === 'error'
723
- ? 'bg-red-50 border border-red-200'
724
- : 'bg-blue-50 border border-blue-200'
725
- }`}
726
- >
727
- <div className="flex">
728
- <div className="flex-shrink-0">
729
- {submissionState.status === 'success' || deleteState.status === 'success' ? (
730
- <CheckCircleIcon className="h-5 w-5 text-green-400" />
731
- ) : submissionState.status === 'error' || deleteState.status === 'error' ? (
732
- <ExclamationCircleIcon className="h-5 w-5 text-red-400" />
733
- ) : (
734
- <div className="h-5 w-5 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
735
- )}
736
- </div>
737
- <div className="ml-3">
738
- <p
739
- className={`text-sm font-medium ${
740
- submissionState.status === 'success' || deleteState.status === 'success'
741
- ? 'text-green-800'
742
- : submissionState.status === 'error' || deleteState.status === 'error'
743
- ? 'text-red-800'
744
- : 'text-blue-800'
745
- }`}
746
- >
747
- {submissionState.status === 'loading'
748
- ? `Updating ${toReadableText(model.name)}...`
749
- : deleteState.status === 'loading'
750
- ? `Deleting ${toReadableText(model.name)}...`
751
- : submissionState.message || deleteState.message}
752
- </p>
753
- </div>
754
- </div>
755
- </div>
756
- )}
757
-
758
- {/* Form */}
759
- <div className="bg-white dark:bg-gray-800 shadow-sm rounded-lg">
760
- <div className="px-6 py-8">
761
- <Form
762
- id={`edit-${model.name.toLowerCase()}-form`}
763
- fields={formFields}
764
- submit={handleSubmit}
765
- disabled={submissionState.status === 'loading' || deleteState.status === 'loading'}
766
- defaultValues={initialValues}
767
- theme={formTheme}
768
- />
769
- </div>
770
- </div>
771
- </div>
772
- )
773
- }
774
-
775
- export function AdminDataEditErrorBoundary({ error }: Readonly<{ error: Error }>) {
776
- return <ErrorBoundary error={error} />
777
- }