@nestledjs/data-browser 0.1.2 → 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.
- package/index.d.ts +15 -0
- package/index.js +64 -0
- package/lib/admin-data.d.ts +2 -0
- package/lib/components/filters/DateRangeFilter.d.ts +7 -0
- package/lib/components/filters/DateRangeFilter.js +105 -0
- package/lib/components/filters/NumberRangeFilter.d.ts +8 -0
- package/lib/components/filters/NumberRangeFilter.js +115 -0
- package/lib/components/filters/RelationComponents.d.ts +32 -0
- package/lib/components/filters/RelationComponents.js +265 -0
- package/lib/components/filters/RelationFilterField.d.ts +8 -0
- package/lib/components/filters/RelationFilterField.js +116 -0
- package/lib/components/filters/index.d.ts +4 -0
- package/lib/components/index.d.ts +2 -0
- package/lib/components/shared/AdminBreadcrumbs.d.ts +13 -0
- package/lib/components/shared/AdminBreadcrumbs.js +103 -0
- package/lib/components/shared/AdminErrorStates.d.ts +30 -0
- package/lib/components/shared/AdminErrorStates.js +214 -0
- package/lib/components/shared/AdminStatusDisplay.d.ts +21 -0
- package/lib/components/shared/AdminStatusDisplay.js +205 -0
- package/lib/components/shared/index.d.ts +3 -0
- package/lib/context/AdminDataContext.d.ts +47 -0
- package/lib/context/AdminDataContext.js +28 -0
- package/lib/hooks/useAdminList.d.ts +5 -0
- package/lib/hooks/useAdminList.js +41 -0
- package/lib/hooks/useClickOutside.d.ts +2 -0
- package/lib/hooks/useClickOutside.js +13 -0
- package/lib/hooks/useDebounce.d.ts +1 -0
- package/lib/hooks/useDebounce.js +13 -0
- package/lib/hooks/useRelationData.d.ts +7 -0
- package/lib/hooks/useRelationData.js +64 -0
- package/lib/layouts/AdminDataLayout.d.ts +5 -0
- package/lib/layouts/AdminDataLayout.js +315 -0
- package/lib/pages/AdminDataCreatePage.d.ts +4 -0
- package/lib/pages/AdminDataCreatePage.js +487 -0
- package/lib/pages/AdminDataEditPage.d.ts +4 -0
- package/lib/pages/AdminDataEditPage.js +903 -0
- package/lib/pages/AdminDataIndexPage.d.ts +4 -0
- package/lib/pages/AdminDataIndexPage.js +91 -0
- package/lib/pages/AdminDataListPage.d.ts +9 -0
- package/lib/pages/AdminDataListPage.js +1218 -0
- package/lib/types/index.d.ts +60 -0
- package/lib/types/index.js +16 -0
- package/lib/utils/graphql-utils.d.ts +25 -0
- package/lib/utils/graphql-utils.js +265 -0
- package/lib/utils/secure-storage.d.ts +50 -0
- package/lib/utils/secure-storage.js +145 -0
- package/lib/utils/string-utils.d.ts +6 -0
- package/lib/utils/string-utils.js +27 -0
- package/package.json +8 -2
- package/.babelrc +0 -12
- package/LICENSE +0 -21
- package/eslint.config.mjs +0 -12
- package/project.json +0 -35
- package/src/index.ts +0 -28
- package/src/lib/admin-data.spec.tsx +0 -10
- package/src/lib/admin-data.tsx +0 -9
- package/src/lib/components/filters/DateRangeFilter.tsx +0 -88
- package/src/lib/components/filters/NumberRangeFilter.tsx +0 -111
- package/src/lib/components/filters/RelationComponents.tsx +0 -176
- package/src/lib/components/filters/RelationFilterField.tsx +0 -106
- package/src/lib/components/filters/index.ts +0 -5
- package/src/lib/components/index.ts +0 -2
- package/src/lib/components/shared/AdminBreadcrumbs.tsx +0 -88
- package/src/lib/components/shared/AdminErrorStates.tsx +0 -180
- package/src/lib/components/shared/AdminStatusDisplay.tsx +0 -292
- package/src/lib/components/shared/index.ts +0 -3
- package/src/lib/context/AdminDataContext.tsx +0 -74
- package/src/lib/hooks/useAdminList.ts +0 -42
- package/src/lib/hooks/useClickOutside.ts +0 -21
- package/src/lib/hooks/useDebounce.ts +0 -16
- package/src/lib/hooks/useRelationData.ts +0 -114
- package/src/lib/layouts/AdminDataLayout.tsx +0 -251
- package/src/lib/pages/AdminDataCreatePage.tsx +0 -415
- package/src/lib/pages/AdminDataEditPage.tsx +0 -777
- package/src/lib/pages/AdminDataIndexPage.tsx +0 -50
- package/src/lib/pages/AdminDataListPage.tsx +0 -921
- package/src/lib/types/index.ts +0 -51
- package/src/lib/utils/graphql-utils.ts +0 -538
- package/src/lib/utils/secure-storage.ts +0 -305
- package/src/lib/utils/string-utils.ts +0 -53
- package/tsconfig.json +0 -17
- package/tsconfig.lib.json +0 -20
- 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
|
-
}
|