@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.
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 +8 -2
  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,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
- }