@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
package/src/lib/types/index.ts
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
// State management types for admin list page
|
|
2
|
-
export interface AdminListState {
|
|
3
|
-
search: string | null
|
|
4
|
-
debouncedSearch: string | null
|
|
5
|
-
skip: number
|
|
6
|
-
pageSize: number
|
|
7
|
-
sort: { orderBy: string; orderDirection: string }
|
|
8
|
-
visibleColumns: string[]
|
|
9
|
-
showColumnSelector: boolean
|
|
10
|
-
searchFields: string[]
|
|
11
|
-
showSearchFieldSelector: boolean
|
|
12
|
-
filters: Record<string, unknown>
|
|
13
|
-
showFilters: boolean
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export type AdminListAction =
|
|
17
|
-
| { type: 'SET_SEARCH'; payload: string | null }
|
|
18
|
-
| { type: 'SET_DEBOUNCED_SEARCH'; payload: string | null }
|
|
19
|
-
| { type: 'SET_SKIP'; payload: number }
|
|
20
|
-
| { type: 'SET_PAGE_SIZE'; payload: number }
|
|
21
|
-
| { type: 'SET_SORT'; payload: { orderBy: string; orderDirection: string } }
|
|
22
|
-
| { type: 'SET_VISIBLE_COLUMNS'; payload: string[] }
|
|
23
|
-
| { type: 'TOGGLE_COLUMN_SELECTOR' }
|
|
24
|
-
| { type: 'SET_SEARCH_FIELDS'; payload: string[] }
|
|
25
|
-
| { type: 'TOGGLE_SEARCH_FIELD_SELECTOR' }
|
|
26
|
-
| { type: 'SET_FILTERS'; payload: Record<string, unknown> }
|
|
27
|
-
| { type: 'TOGGLE_FILTERS' }
|
|
28
|
-
| { type: 'RESET_PAGINATION' }
|
|
29
|
-
| { type: 'RESET_FILTERS' }
|
|
30
|
-
|
|
31
|
-
export const initialState: AdminListState = {
|
|
32
|
-
search: '',
|
|
33
|
-
debouncedSearch: '',
|
|
34
|
-
skip: 0,
|
|
35
|
-
pageSize: 20,
|
|
36
|
-
sort: { orderBy: 'id', orderDirection: 'desc' },
|
|
37
|
-
visibleColumns: [],
|
|
38
|
-
showColumnSelector: false,
|
|
39
|
-
searchFields: [],
|
|
40
|
-
showSearchFieldSelector: false,
|
|
41
|
-
filters: {},
|
|
42
|
-
showFilters: false,
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Database model type (from @nestled-template/shared/utils originally)
|
|
46
|
-
export interface DatabaseModel {
|
|
47
|
-
name: string
|
|
48
|
-
fields: any[]
|
|
49
|
-
pluralModelPropertyName?: string
|
|
50
|
-
// Add other properties as needed
|
|
51
|
-
}
|
|
@@ -1,538 +0,0 @@
|
|
|
1
|
-
import { type FormField, FormFieldClass } from '@nestledjs/forms'
|
|
2
|
-
import { getPluralName } from '@nestledjs/helpers'
|
|
3
|
-
import type { DatabaseModel } from '../types'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Dynamically get enum values from the SDK
|
|
7
|
-
* This makes the utility portable across different projects by avoiding hardcoded enum imports
|
|
8
|
-
*/
|
|
9
|
-
function getEnumValues(sdk: any, enumType: string): string[] | null {
|
|
10
|
-
try {
|
|
11
|
-
// Try to get the enum from the SDK
|
|
12
|
-
const enumObject = (sdk as any)[enumType]
|
|
13
|
-
|
|
14
|
-
if (!enumObject || typeof enumObject !== 'object') {
|
|
15
|
-
return null
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
// Extract enum values (handle both string enums and numeric enums)
|
|
19
|
-
const values = Object.values(enumObject).filter(value => typeof value === 'string')
|
|
20
|
-
|
|
21
|
-
if (values.length === 0) {
|
|
22
|
-
// Try to get values from Object.keys for string enums where keys === values
|
|
23
|
-
const keys = Object.keys(enumObject).filter(key => isNaN(Number(key)))
|
|
24
|
-
return keys.length > 0 ? keys : null
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return values as string[]
|
|
28
|
-
} catch (error) {
|
|
29
|
-
console.warn(`Failed to get enum values for type ${enumType}:`, error)
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Smart normalization for GraphQL document names
|
|
36
|
-
* Automatically handles acronyms without manual mapping
|
|
37
|
-
*/
|
|
38
|
-
function normalizeModelNameForDocument(modelName: string): string {
|
|
39
|
-
// If it's all uppercase (likely an acronym), convert to proper case
|
|
40
|
-
if (modelName === modelName.toUpperCase() && modelName.length > 1) {
|
|
41
|
-
// For acronyms, only capitalize the first letter for document names
|
|
42
|
-
return modelName.charAt(0).toUpperCase() + modelName.slice(1).toLowerCase()
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// For normal PascalCase names, return as-is
|
|
46
|
-
return modelName
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Get GraphQL documents for admin CRUD operations
|
|
51
|
-
*/
|
|
52
|
-
export function getAdminDocuments(sdk: any, model: DatabaseModel) {
|
|
53
|
-
if (!model || !model.name) {
|
|
54
|
-
throw new Error('Invalid model provided to getAdminDocuments')
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Normalize model name for document lookup (handles FAQ -> Faq, etc.)
|
|
58
|
-
const normalizedModelName = normalizeModelNameForDocument(model.name)
|
|
59
|
-
const normalizedPluralName = normalizeModelNameForDocument(getPluralName(model.name))
|
|
60
|
-
|
|
61
|
-
// Expected document names in the SDK (with __ prefix for admin operations)
|
|
62
|
-
const singleQueryDocumentName = `__Admin${normalizedModelName}Document` // Single item query
|
|
63
|
-
const listQueryDocumentName = `__Admin${normalizedPluralName}Document` // List query
|
|
64
|
-
const updateDocumentName = `__AdminUpdate${normalizedModelName}Document`
|
|
65
|
-
const deleteDocumentName = `__AdminDelete${normalizedModelName}Document`
|
|
66
|
-
const createDocumentName = `__AdminCreate${normalizedModelName}Document`
|
|
67
|
-
|
|
68
|
-
const documents = {
|
|
69
|
-
query: (sdk as Record<string, any>)[singleQueryDocumentName], // For single item
|
|
70
|
-
listQuery: (sdk as Record<string, any>)[listQueryDocumentName], // For lists
|
|
71
|
-
update: (sdk as Record<string, any>)[updateDocumentName],
|
|
72
|
-
delete: (sdk as Record<string, any>)[deleteDocumentName],
|
|
73
|
-
create: (sdk as Record<string, any>)[createDocumentName],
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Validate that required documents exist
|
|
77
|
-
const missingDocuments: string[] = []
|
|
78
|
-
if (!documents.query) missingDocuments.push(singleQueryDocumentName)
|
|
79
|
-
if (!documents.listQuery) missingDocuments.push(listQueryDocumentName)
|
|
80
|
-
if (!documents.create) missingDocuments.push(createDocumentName)
|
|
81
|
-
if (!documents.update) missingDocuments.push(updateDocumentName)
|
|
82
|
-
if (!documents.delete) missingDocuments.push(deleteDocumentName)
|
|
83
|
-
|
|
84
|
-
if (missingDocuments.length > 0) {
|
|
85
|
-
console.error(`[GraphQL Documents] Missing documents for model "${model.name}":`, {
|
|
86
|
-
model: model.name,
|
|
87
|
-
normalizedModelName,
|
|
88
|
-
normalizedPluralName,
|
|
89
|
-
missingDocuments,
|
|
90
|
-
availableDocuments: Object.keys(sdk).filter(
|
|
91
|
-
key => key.includes('Admin') && key.includes('Document'),
|
|
92
|
-
),
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
throw new Error(
|
|
96
|
-
`Missing GraphQL documents for model "${model.name}": ${missingDocuments.join(', ')}. Please ensure the API server is running and the GraphQL schema is up to date.`,
|
|
97
|
-
)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
return documents
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Get the GraphQL mutation name for a given operation
|
|
105
|
-
*/
|
|
106
|
-
export function getMutationName(
|
|
107
|
-
model: DatabaseModel,
|
|
108
|
-
operation: 'create' | 'update' | 'delete',
|
|
109
|
-
): string {
|
|
110
|
-
const modelName = model.name.charAt(0).toLowerCase() + model.name.slice(1)
|
|
111
|
-
|
|
112
|
-
switch (operation) {
|
|
113
|
-
case 'create':
|
|
114
|
-
return `create${model.name}`
|
|
115
|
-
case 'update':
|
|
116
|
-
return `update${model.name}`
|
|
117
|
-
case 'delete':
|
|
118
|
-
return `delete${model.name}`
|
|
119
|
-
default:
|
|
120
|
-
return modelName
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Build form fields for a model and operation
|
|
126
|
-
*/
|
|
127
|
-
export function buildFormFields(
|
|
128
|
-
sdk: any,
|
|
129
|
-
model: DatabaseModel,
|
|
130
|
-
operation: 'create' | 'update',
|
|
131
|
-
currentItem?: any,
|
|
132
|
-
isSubmitting?: boolean,
|
|
133
|
-
): FormField[] {
|
|
134
|
-
// Filter out computed/readonly fields for forms
|
|
135
|
-
const editableFields = model.fields.filter((field: any) => {
|
|
136
|
-
// Skip ID field for create operations
|
|
137
|
-
if (operation === 'create' && field.isId) return false
|
|
138
|
-
|
|
139
|
-
// Skip computed fields
|
|
140
|
-
if (field.isReadOnly || field.isGenerated) return false
|
|
141
|
-
|
|
142
|
-
// Skip timestamps for manual editing (they're auto-managed)
|
|
143
|
-
if (field.isUpdatedAt || field.name === 'createdAt') return false
|
|
144
|
-
|
|
145
|
-
// Skip relation fields for now (they need special handling)
|
|
146
|
-
if (field.relationName && field.isList) return false
|
|
147
|
-
|
|
148
|
-
return true
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
// Convert to form field format using FormFieldClass
|
|
152
|
-
const formFields = editableFields.map((field: any) => {
|
|
153
|
-
const label = field.name
|
|
154
|
-
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
155
|
-
.replace(/^./, (str: string) => str.toUpperCase())
|
|
156
|
-
|
|
157
|
-
// Get initial value from currentItem for update operations
|
|
158
|
-
let initialValue = currentItem && operation === 'update' ? currentItem[field.name] : undefined
|
|
159
|
-
|
|
160
|
-
// Convert Date objects to proper format for date/datetime fields
|
|
161
|
-
if (field.type.toLowerCase() === 'datetime' || field.type.toLowerCase() === 'date') {
|
|
162
|
-
if (initialValue instanceof Date || (initialValue && typeof initialValue === 'string')) {
|
|
163
|
-
try {
|
|
164
|
-
const dateValue = initialValue instanceof Date ? initialValue : new Date(initialValue)
|
|
165
|
-
|
|
166
|
-
if (field.type.toLowerCase() === 'date') {
|
|
167
|
-
// Date fields: YYYY-MM-DD format
|
|
168
|
-
initialValue = dateValue.toISOString().split('T')[0]
|
|
169
|
-
} else {
|
|
170
|
-
// DateTime fields: YYYY-MM-DDTHH:mm format (for datetime-local input)
|
|
171
|
-
const isoString = dateValue.toISOString()
|
|
172
|
-
// Extract YYYY-MM-DDTHH:mm (remove seconds and timezone)
|
|
173
|
-
initialValue = isoString.substring(0, 16)
|
|
174
|
-
}
|
|
175
|
-
} catch (e) {
|
|
176
|
-
console.warn(`Failed to convert date value for field ${field.name}:`, e)
|
|
177
|
-
initialValue = ''
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Convert relation objects to their ID strings
|
|
183
|
-
if (initialValue && typeof initialValue === 'object' && !Array.isArray(initialValue) && field.relationName) {
|
|
184
|
-
const relationObj = initialValue as Record<string, unknown>
|
|
185
|
-
if (relationObj.id && typeof relationObj.id === 'string') {
|
|
186
|
-
initialValue = relationObj.id
|
|
187
|
-
} else {
|
|
188
|
-
initialValue = ''
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Convert null to empty string for form fields (except boolean fields)
|
|
193
|
-
if (initialValue === null && field.type.toLowerCase() !== 'boolean') {
|
|
194
|
-
initialValue = ''
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Arrays (isList) should be optional in forms regardless of model optionality
|
|
198
|
-
const isArrayField = Boolean(field.isList)
|
|
199
|
-
const isRequired = isArrayField ? false : !field.isOptional
|
|
200
|
-
|
|
201
|
-
const options = {
|
|
202
|
-
label,
|
|
203
|
-
required: isRequired,
|
|
204
|
-
...(initialValue !== undefined && { value: initialValue }),
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Determine field type based on Prisma type
|
|
208
|
-
switch (field.type.toLowerCase()) {
|
|
209
|
-
case 'string':
|
|
210
|
-
if (field.name.toLowerCase().includes('email')) {
|
|
211
|
-
return FormFieldClass.email(field.name, options)
|
|
212
|
-
}
|
|
213
|
-
// For long text fields, use textarea
|
|
214
|
-
if (
|
|
215
|
-
field.name.toLowerCase().includes('description') ||
|
|
216
|
-
field.name.toLowerCase().includes('content') ||
|
|
217
|
-
field.name.toLowerCase().includes('notes')
|
|
218
|
-
) {
|
|
219
|
-
return FormFieldClass.textArea(field.name, options)
|
|
220
|
-
}
|
|
221
|
-
return FormFieldClass.text(field.name, options)
|
|
222
|
-
|
|
223
|
-
case 'int':
|
|
224
|
-
case 'bigint':
|
|
225
|
-
return FormFieldClass.text(field.name, options)
|
|
226
|
-
|
|
227
|
-
case 'float':
|
|
228
|
-
case 'decimal':
|
|
229
|
-
return FormFieldClass.text(field.name, options)
|
|
230
|
-
|
|
231
|
-
case 'boolean': {
|
|
232
|
-
// Boolean fields should never be "required" in forms, even if required in DB
|
|
233
|
-
// Required in DB means "must have value (true OR false)", not "must be true"
|
|
234
|
-
// For boolean fields, convert null/undefined to false for checkbox display
|
|
235
|
-
const booleanValue =
|
|
236
|
-
currentItem && operation === 'update' ? Boolean(currentItem[field.name]) : false
|
|
237
|
-
return FormFieldClass.checkbox(field.name, {
|
|
238
|
-
...options,
|
|
239
|
-
required: false,
|
|
240
|
-
...(operation === 'update' && { value: booleanValue }),
|
|
241
|
-
})
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
case 'datetime':
|
|
245
|
-
return FormFieldClass.dateTimePicker(field.name, options)
|
|
246
|
-
|
|
247
|
-
case 'date':
|
|
248
|
-
return FormFieldClass.datePicker(field.name, options)
|
|
249
|
-
|
|
250
|
-
default:
|
|
251
|
-
// Handle enum fields first (check if field type exists in the SDK)
|
|
252
|
-
const enumValues = getEnumValues(sdk, field.type)
|
|
253
|
-
if (enumValues) {
|
|
254
|
-
const selectOptions = enumValues.map((value: string) => ({
|
|
255
|
-
value,
|
|
256
|
-
label: value
|
|
257
|
-
.replace(/_/g, ' ')
|
|
258
|
-
.toLowerCase()
|
|
259
|
-
.replace(/^./, (str: string) => str.toUpperCase()),
|
|
260
|
-
}))
|
|
261
|
-
|
|
262
|
-
return FormFieldClass.select(field.name, {
|
|
263
|
-
...options,
|
|
264
|
-
options: selectOptions,
|
|
265
|
-
})
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Handle relation fields with Apollo-powered select dropdowns
|
|
269
|
-
if (field.relationName && !field.isList) {
|
|
270
|
-
const relationFieldName = field.relationFromFields?.[0] || field.name
|
|
271
|
-
// For relation fields, get the foreign key value or extract ID from relation object
|
|
272
|
-
let relationValue =
|
|
273
|
-
currentItem && operation === 'update' ? currentItem[relationFieldName] : undefined
|
|
274
|
-
|
|
275
|
-
// If relationValue is an object (like {__typename: 'Course', id: '...' }), extract the ID
|
|
276
|
-
if (relationValue && typeof relationValue === 'object' && relationValue.id) {
|
|
277
|
-
relationValue = relationValue.id
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Convert null to empty string
|
|
281
|
-
if (relationValue === null) {
|
|
282
|
-
relationValue = ''
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// Use Apollo-powered select for relationships
|
|
286
|
-
// Try to get the Admin GraphQL document for the relation type (with __ prefix)
|
|
287
|
-
const adminDocumentName = `__Admin${field.type}sDocument` // e.g., __AdminCoursesDocument
|
|
288
|
-
const regularDocumentName = `${field.type}sDocument` // e.g., CoursesDocument (fallback)
|
|
289
|
-
const relationDocument =
|
|
290
|
-
(sdk as any)[adminDocumentName] || (sdk as any)[regularDocumentName]
|
|
291
|
-
|
|
292
|
-
if (relationDocument) {
|
|
293
|
-
// Use single field based on model type (like existing working examples)
|
|
294
|
-
let searchField = 'id' // Safe fallback
|
|
295
|
-
|
|
296
|
-
if (field.type === 'Course') {
|
|
297
|
-
searchField = 'title' // Course has title
|
|
298
|
-
} else if (field.type === 'User') {
|
|
299
|
-
searchField = 'name' // User likely has name
|
|
300
|
-
} else if (field.type === 'Program') {
|
|
301
|
-
searchField = 'name' // Program has name
|
|
302
|
-
} else {
|
|
303
|
-
// Try title first, then name, then id
|
|
304
|
-
searchField = 'title' // Most common
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Create initial option from current item if we're in edit mode
|
|
308
|
-
let initialOptions: Array<{ value: string; label: string }> = []
|
|
309
|
-
if (currentItem && operation === 'update') {
|
|
310
|
-
const currentRelationData = currentItem[field.name] // e.g., course: {__typename: 'Course', id: '...'}
|
|
311
|
-
if (
|
|
312
|
-
currentRelationData &&
|
|
313
|
-
typeof currentRelationData === 'object' &&
|
|
314
|
-
currentRelationData.id
|
|
315
|
-
) {
|
|
316
|
-
const displayLabel =
|
|
317
|
-
currentRelationData[searchField] ||
|
|
318
|
-
currentRelationData.title ||
|
|
319
|
-
currentRelationData.name ||
|
|
320
|
-
currentRelationData.id
|
|
321
|
-
initialOptions = [
|
|
322
|
-
{
|
|
323
|
-
value: currentRelationData.id,
|
|
324
|
-
label: displayLabel,
|
|
325
|
-
},
|
|
326
|
-
]
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
return FormFieldClass.searchSelectApollo(relationFieldName, {
|
|
331
|
-
label: label, // Remove "ID" suffix - just use the field name
|
|
332
|
-
required: options.required,
|
|
333
|
-
dataType: field.type.toLowerCase() + 's', // e.g., Course → courses
|
|
334
|
-
document: relationDocument,
|
|
335
|
-
searchFields: [searchField], // For searching
|
|
336
|
-
selectOptionsFunction: (items: unknown[]) => {
|
|
337
|
-
// Combine initial options with query results (avoiding duplicates)
|
|
338
|
-
const queryOptions = items.map((item: any) => ({
|
|
339
|
-
value: item.id,
|
|
340
|
-
label: item[searchField] || item.title || item.name || item.id,
|
|
341
|
-
}))
|
|
342
|
-
|
|
343
|
-
// Add initial option if it's not already in the query results
|
|
344
|
-
const allOptions = [...initialOptions]
|
|
345
|
-
queryOptions.forEach(option => {
|
|
346
|
-
if (!allOptions.find(existing => existing.value === option.value)) {
|
|
347
|
-
allOptions.push(option)
|
|
348
|
-
}
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
return allOptions
|
|
352
|
-
},
|
|
353
|
-
...(initialOptions.length > 0 && { initialOptions }), // Provide initial options if available
|
|
354
|
-
...(relationValue !== undefined && { value: relationValue }),
|
|
355
|
-
})
|
|
356
|
-
} else {
|
|
357
|
-
// Fallback to text input if document not found
|
|
358
|
-
console.warn(
|
|
359
|
-
`GraphQL document ${adminDocumentName} or ${regularDocumentName} not found for relation ${field.type}. Using text input instead.`,
|
|
360
|
-
)
|
|
361
|
-
return FormFieldClass.text(relationFieldName, {
|
|
362
|
-
label: `${label} ID`,
|
|
363
|
-
required: options.required,
|
|
364
|
-
helpText: 'Enter the ID of the related record',
|
|
365
|
-
...(relationValue !== undefined && { value: relationValue }),
|
|
366
|
-
})
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Handle legacy enum fields (with enumValues property)
|
|
371
|
-
if (field.kind === 'enum' && field.enumValues) {
|
|
372
|
-
const selectOptions = field.enumValues.map((value: string) => ({
|
|
373
|
-
value,
|
|
374
|
-
label: value
|
|
375
|
-
.replace(/_/g, ' ')
|
|
376
|
-
.toLowerCase()
|
|
377
|
-
.replace(/^./, (str: string) => str.toUpperCase()),
|
|
378
|
-
}))
|
|
379
|
-
|
|
380
|
-
return FormFieldClass.select(field.name, {
|
|
381
|
-
...options,
|
|
382
|
-
options: selectOptions,
|
|
383
|
-
})
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return FormFieldClass.text(field.name, options)
|
|
387
|
-
}
|
|
388
|
-
})
|
|
389
|
-
|
|
390
|
-
// Add submit button with loading state
|
|
391
|
-
const loadingText = operation === 'create' ? 'Creating...' : 'Updating...'
|
|
392
|
-
const defaultText = operation === 'create' ? 'Create' : 'Update'
|
|
393
|
-
const buttonText = isSubmitting ? loadingText : defaultText
|
|
394
|
-
|
|
395
|
-
formFields.push(
|
|
396
|
-
FormFieldClass.button('submit', {
|
|
397
|
-
text: buttonText,
|
|
398
|
-
type: 'submit',
|
|
399
|
-
variant: 'primary',
|
|
400
|
-
disabled: isSubmitting,
|
|
401
|
-
}),
|
|
402
|
-
)
|
|
403
|
-
|
|
404
|
-
return formFields
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Fields that should never be sent to mutations
|
|
408
|
-
const SYSTEM_FIELDS = new Set([
|
|
409
|
-
'__typename', // Apollo type annotation
|
|
410
|
-
'id', // Usually passed as separate variable
|
|
411
|
-
'createdAt', // Auto-managed timestamp
|
|
412
|
-
'updatedAt', // Auto-managed timestamp
|
|
413
|
-
'_count', // Prisma count metadata
|
|
414
|
-
'_meta', // Prisma metadata
|
|
415
|
-
])
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Check if a value should be skipped during form cleaning
|
|
419
|
-
*/
|
|
420
|
-
function shouldSkipValue(key: string, value: unknown): boolean {
|
|
421
|
-
return SYSTEM_FIELDS.has(key) || value === undefined
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* Convert string values to appropriate types (number, boolean, null, datetime)
|
|
426
|
-
*/
|
|
427
|
-
function convertStringValue(value: string, field?: any): string | number | boolean | null {
|
|
428
|
-
// Convert empty strings to null for optional fields
|
|
429
|
-
if (value === '') {
|
|
430
|
-
return null
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Handle datetime-local format (YYYY-MM-DDTHH:mm) - convert to ISO string
|
|
434
|
-
if (field?.type.toLowerCase() === 'datetime' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(value)) {
|
|
435
|
-
try {
|
|
436
|
-
// datetime-local values are in local time, convert to ISO string
|
|
437
|
-
const date = new Date(value)
|
|
438
|
-
return date.toISOString()
|
|
439
|
-
} catch (e) {
|
|
440
|
-
console.warn(`Failed to convert datetime-local value: ${value}`)
|
|
441
|
-
return value
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// Handle boolean strings
|
|
446
|
-
if (value === 'true') return true
|
|
447
|
-
if (value === 'false') return false
|
|
448
|
-
|
|
449
|
-
// Handle numeric strings (from form inputs)
|
|
450
|
-
const numericPattern = /^\d+(\.\d+)?$/
|
|
451
|
-
if (numericPattern.test(value)) {
|
|
452
|
-
const numericValue = Number(value)
|
|
453
|
-
if (!isNaN(numericValue)) {
|
|
454
|
-
return numericValue
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Return string as-is
|
|
459
|
-
return value
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Process nested objects recursively
|
|
464
|
-
*/
|
|
465
|
-
function processNestedObject(
|
|
466
|
-
value: Record<string, unknown>,
|
|
467
|
-
model?: DatabaseModel,
|
|
468
|
-
): Record<string, unknown> | null {
|
|
469
|
-
const cleanedNested = cleanFormInput(value, model)
|
|
470
|
-
// Only include if the nested object has any fields after cleaning
|
|
471
|
-
return Object.keys(cleanedNested).length > 0 ? cleanedNested : null
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Clean form input data for GraphQL mutations
|
|
476
|
-
* Removes Apollo metadata and system fields
|
|
477
|
-
*/
|
|
478
|
-
export function cleanFormInput(
|
|
479
|
-
input: Record<string, unknown>,
|
|
480
|
-
model?: DatabaseModel,
|
|
481
|
-
): Record<string, unknown> {
|
|
482
|
-
const cleaned: Record<string, unknown> = {}
|
|
483
|
-
|
|
484
|
-
// Get boolean field names for special handling
|
|
485
|
-
const booleanFields = new Set(
|
|
486
|
-
model?.fields
|
|
487
|
-
?.filter(field => field.type.toLowerCase() === 'boolean')
|
|
488
|
-
?.map(field => field.name) || [],
|
|
489
|
-
)
|
|
490
|
-
|
|
491
|
-
for (const [key, value] of Object.entries(input)) {
|
|
492
|
-
// Special handling for boolean fields: convert undefined to false
|
|
493
|
-
if (booleanFields.has(key) && value === undefined) {
|
|
494
|
-
cleaned[key] = false
|
|
495
|
-
continue
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// Skip system fields and undefined values
|
|
499
|
-
if (shouldSkipValue(key, value)) {
|
|
500
|
-
continue
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
// Handle string values with type conversion
|
|
504
|
-
if (typeof value === 'string') {
|
|
505
|
-
// Find the field definition to help with type conversion
|
|
506
|
-
const field = model?.fields?.find((f: any) => f.name === key)
|
|
507
|
-
const convertedValue = convertStringValue(value, field)
|
|
508
|
-
if (convertedValue !== null) {
|
|
509
|
-
cleaned[key] = convertedValue
|
|
510
|
-
}
|
|
511
|
-
continue
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// Handle searchSelectApollo option objects: {value: "id", label: "name"}
|
|
515
|
-
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
|
516
|
-
const obj = value as Record<string, unknown>
|
|
517
|
-
|
|
518
|
-
// Check if this looks like a searchSelectApollo option object
|
|
519
|
-
if (obj.value !== undefined && obj.label !== undefined && typeof obj.value === 'string') {
|
|
520
|
-
// Extract just the value (the ID) for GraphQL mutations
|
|
521
|
-
cleaned[key] = obj.value
|
|
522
|
-
continue
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Handle other nested objects
|
|
526
|
-
const processedNested = processNestedObject(obj, model)
|
|
527
|
-
if (processedNested !== null) {
|
|
528
|
-
cleaned[key] = processedNested
|
|
529
|
-
}
|
|
530
|
-
continue
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Pass through other values as-is
|
|
534
|
-
cleaned[key] = value
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
return cleaned
|
|
538
|
-
}
|