@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,921 +0,0 @@
1
- import { useQuery } from '@apollo/client/react'
2
- import { DataTable, ErrorBoundary } from '@nestledjs/shared-components'
3
- import { getPluralName } from '@nestledjs/helpers'
4
- import { AdminLocalStorage } from '../utils/secure-storage'
5
- import { formatFieldName, kebabCase } from '../utils/string-utils'
6
- import { useCallback, useEffect, useMemo } from 'react'
7
- import { Link, useParams } from 'react-router'
8
- import { DateRangeFilter, NumberRangeFilter, RelationFilterField } from '../components/filters'
9
- import { useAdminList } from '../hooks/useAdminList'
10
- import { getAdminDocuments } from '../utils/graphql-utils'
11
- import { useAdminDataContext } from '../context/AdminDataContext'
12
-
13
- interface AdminDataListPageProps {
14
- /** Optional model name - if not provided, will read from route params */
15
- modelName?: string
16
- }
17
-
18
- export function AdminDataListPage({ modelName: propModelName }: AdminDataListPageProps = {}) {
19
- const params = useParams()
20
- const { sdk, databaseModels, basePath = '/admin/data' } = useAdminDataContext()
21
-
22
- // Helper function to get enum values from SDK
23
- const getEnumValues = useCallback((enumType: string): string[] | null => {
24
- try {
25
- const enumObject = (sdk as any)[enumType]
26
-
27
- if (!enumObject || typeof enumObject !== 'object') {
28
- return null
29
- }
30
-
31
- const values = Object.values(enumObject).filter(value => typeof value === 'string')
32
-
33
- if (values.length === 0) {
34
- const keys = Object.keys(enumObject).filter(key => isNaN(Number(key)))
35
- return keys.length > 0 ? keys : null
36
- }
37
-
38
- return values as string[]
39
- } catch (error) {
40
- console.warn(`Failed to get enum values for type ${enumType}:`, error)
41
- return null
42
- }
43
- }, [sdk])
44
-
45
- // Consolidated state management with useReducer for better performance
46
- const { state, dispatch } = useAdminList()
47
-
48
- // Destructure state for easier access and backwards compatibility
49
- const {
50
- search,
51
- debouncedSearch,
52
- skip,
53
- pageSize,
54
- sort,
55
- visibleColumns,
56
- showColumnSelector,
57
- searchFields,
58
- filters,
59
- showFilters,
60
- showSearchFieldSelector,
61
- } = state
62
-
63
- // State setters using dispatch for backwards compatibility - memoized to prevent infinite loops
64
- const setSkip = useCallback(
65
- (skip: number) => dispatch({ type: 'SET_SKIP', payload: skip }),
66
- [dispatch],
67
- )
68
- const setFilters = useCallback(
69
- (filters: Record<string, any>) => dispatch({ type: 'SET_FILTERS', payload: filters }),
70
- [dispatch],
71
- )
72
- const setShowFilters = useCallback(
73
- (show: boolean) => {
74
- if (show !== showFilters) dispatch({ type: 'TOGGLE_FILTERS' })
75
- },
76
- [showFilters, dispatch],
77
- )
78
- const setVisibleColumns = useCallback(
79
- (columns: string[]) => dispatch({ type: 'SET_VISIBLE_COLUMNS', payload: columns }),
80
- [dispatch],
81
- )
82
- const setShowColumnSelector = useCallback(
83
- (show: boolean) => {
84
- if (show !== showColumnSelector) dispatch({ type: 'TOGGLE_COLUMN_SELECTOR' })
85
- },
86
- [showColumnSelector, dispatch],
87
- )
88
- const setSearchFields = useCallback(
89
- (fields: string[]) => dispatch({ type: 'SET_SEARCH_FIELDS', payload: fields }),
90
- [dispatch],
91
- )
92
- const setShowSearchFieldSelector = useCallback(
93
- (show: boolean) => {
94
- if (show !== showSearchFieldSelector) dispatch({ type: 'TOGGLE_SEARCH_FIELD_SELECTOR' })
95
- },
96
- [showSearchFieldSelector, dispatch],
97
- )
98
-
99
- // Debounce search input
100
- useEffect(() => {
101
- const timer = setTimeout(() => {
102
- dispatch({ type: 'SET_DEBOUNCED_SEARCH', payload: state.search })
103
- }, 500) // 500ms debounce delay
104
-
105
- return () => clearTimeout(timer)
106
- }, [state.search])
107
-
108
- // Memoize expensive model finding calculation
109
- const pluralParam = propModelName || (params?.dataTypePlural ?? '')
110
- const model = useMemo(() => {
111
- if (propModelName) {
112
- // Direct model name provided
113
- return databaseModels.find((m: any) => m.name === propModelName)
114
- }
115
-
116
- if (!pluralParam) return undefined
117
- // Convert kebab-case URL param back to find matching model
118
- // URL: "event-recurring-patterns" should match model where kebabCase(getPluralName(model.name)) === pluralParam
119
- return databaseModels.find((m: any) => {
120
- const modelUrlName = kebabCase(getPluralName(m.name))
121
- return modelUrlName.toLowerCase() === pluralParam.toLowerCase()
122
- })
123
- }, [propModelName, pluralParam, databaseModels])
124
-
125
- // Get GraphQL documents based on model
126
- const documents = useMemo(() => {
127
- if (!model)
128
- return {
129
- query: undefined,
130
- listQuery: undefined,
131
- update: undefined,
132
- delete: undefined,
133
- create: undefined,
134
- }
135
- return getAdminDocuments(sdk, model)
136
- }, [sdk, model])
137
-
138
- const { listQuery: query } = documents
139
-
140
- // Calculate data path for GraphQL response
141
- const dataPath = useMemo(() => {
142
- return (
143
- model?.pluralModelPropertyName ||
144
- pluralParam.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
145
- )
146
- }, [model, pluralParam])
147
-
148
- // Calculate pagination path for GraphQL response
149
- const paginationPath = useMemo(() => {
150
- return model ? `${model.pluralModelPropertyName}_meta` : `${dataPath}_meta`
151
- }, [model, dataPath])
152
-
153
- // Get all field names from model
154
- const fieldNames = useMemo(() => {
155
- if (!model) return []
156
- return model.fields
157
- .filter((field: any) => !field.relationName || !field.isList) // Include scalar fields and single relations
158
- .map((field: any) => field.name)
159
- }, [model])
160
-
161
- // Get filterable field names (excluding text fields - use search for those)
162
- const filterableFieldNames = useMemo(() => {
163
- if (!model) return []
164
-
165
- return model.fields
166
- .filter((field: any) => {
167
- // Include simple scalar fields that are good for filtering
168
- if (field.relationName && !field.isList) return true // Single relations
169
- if (field.relationName) return false // Skip list relations
170
- if (field.isList) return false
171
- if (['id', 'createdAt', 'updatedAt'].includes(field.name)) return false
172
-
173
- const fieldType = field.type.toLowerCase()
174
- // Removed 'string' from filterable types - use search instead for text fields
175
- return (
176
- ['boolean', 'int', 'float', 'date', 'datetime'].includes(fieldType) ||
177
- field.kind === 'enum'
178
- )
179
- })
180
- .map((field: any) => field.name)
181
- }, [model])
182
-
183
- // Get searchable field names for text search
184
- const searchableFieldNames = useMemo(() => {
185
- if (!model) return []
186
-
187
- return model.fields
188
- .filter((field: any) => {
189
- const fieldType = field.type.toLowerCase()
190
- return (
191
- !field.relationName &&
192
- (fieldType === 'string' || fieldType.includes('text') || fieldType === 'boolean')
193
- )
194
- })
195
- .map((field: any) => field.name)
196
- }, [model])
197
-
198
- // Helper function to get good default search fields
199
- const getDefaultSearchFields = useCallback((availableFields: string[]): string[] => {
200
- const goodFields = ['name', 'title', 'email', 'firstName', 'lastName', 'subject']
201
- const matches = goodFields.filter(field => availableFields.includes(field))
202
-
203
- if (matches.length >= 1) {
204
- return matches.slice(0, 2) // Limit to 2 fields for performance
205
- }
206
-
207
- // If no good matches, use first 2 fields
208
- return availableFields.slice(0, Math.min(2, availableFields.length))
209
- }, [])
210
-
211
- // Reset and load per-model settings when model changes
212
- useEffect(() => {
213
- if (!model) return
214
-
215
- // Visible columns (per model), filtered to valid fields
216
- const storedCols = AdminLocalStorage.getColumnVisibility(model.name) || fieldNames.slice(0, 8)
217
- const filteredCols = storedCols.filter((c: string) => fieldNames.includes(c))
218
- setVisibleColumns(filteredCols)
219
-
220
- // Sort (per model); fallback to first field if stored invalid
221
- const storedSort = AdminLocalStorage.getSortPreference(model.name)
222
- const sortFieldValid = storedSort && (storedSort.orderBy === 'id' || fieldNames.includes(storedSort.orderBy))
223
- const nextSort = sortFieldValid ? storedSort! : { orderBy: 'id', orderDirection: 'desc' }
224
- dispatch({ type: 'SET_SORT', payload: nextSort })
225
-
226
- // Search fields (per model), sanitized to valid fields
227
- const storedSearch = AdminLocalStorage.getSearchFields(model.name)
228
- const defaults = getDefaultSearchFields(searchableFieldNames)
229
- // Don't limit stored preferences - user may have selected more than 2 fields
230
- const filteredSearch = (storedSearch || defaults).filter((f: string) => searchableFieldNames.includes(f))
231
- setSearchFields(filteredSearch)
232
-
233
- // Clear per-model transient state
234
- dispatch({ type: 'SET_SEARCH', payload: '' })
235
- dispatch({ type: 'SET_DEBOUNCED_SEARCH', payload: '' })
236
- dispatch({ type: 'RESET_FILTERS' })
237
- dispatch({ type: 'RESET_PAGINATION' })
238
- }, [model?.name, fieldNames, searchableFieldNames, getDefaultSearchFields, setVisibleColumns, setSearchFields, dispatch])
239
-
240
- // Memoized sort handler that prevents unnecessary re-renders
241
- const setSortSafely = useCallback(
242
- (newSort: { orderBy: string; orderDirection: string } | ((prev: { orderBy: string; orderDirection: string }) => { orderBy: string; orderDirection: string })) => {
243
- const resolvedSort = typeof newSort === 'function' ? newSort(sort) : newSort
244
- // Only update if something actually changed
245
- if (resolvedSort.orderBy !== sort.orderBy || resolvedSort.orderDirection !== sort.orderDirection) {
246
- dispatch({ type: 'SET_SORT', payload: resolvedSort })
247
- if (model) {
248
- AdminLocalStorage.setSortPreference(model.name, resolvedSort)
249
- }
250
- }
251
- },
252
- [sort.orderBy, sort.orderDirection, model, dispatch],
253
- )
254
-
255
- // Close dropdowns when clicking outside - memoized to prevent memory leaks
256
- const handleClickOutside = useCallback(
257
- (event: MouseEvent) => {
258
- const target = event.target as Element
259
-
260
- // Check if click is outside the column selector dropdown
261
- if (showColumnSelector) {
262
- const columnSelector = document.querySelector('[data-dropdown="column-selector"]')
263
- if (columnSelector && !columnSelector.contains(target)) {
264
- setShowColumnSelector(false)
265
- }
266
- }
267
-
268
- // Check if click is outside the search field selector dropdown
269
- if (showSearchFieldSelector) {
270
- const searchFieldSelector = document.querySelector(
271
- '[data-dropdown="search-field-selector"]',
272
- )
273
- if (searchFieldSelector && !searchFieldSelector.contains(target)) {
274
- setShowSearchFieldSelector(false)
275
- }
276
- }
277
- },
278
- [
279
- showColumnSelector,
280
- showSearchFieldSelector,
281
- setShowColumnSelector,
282
- setShowSearchFieldSelector,
283
- ],
284
- )
285
-
286
- useEffect(() => {
287
- if (showColumnSelector || showSearchFieldSelector) {
288
- document.addEventListener('mousedown', handleClickOutside)
289
- return () => document.removeEventListener('mousedown', handleClickOutside)
290
- }
291
- }, [showColumnSelector, showSearchFieldSelector, handleClickOutside])
292
-
293
- const variables = {
294
- input: {
295
- take: pageSize,
296
- skip,
297
- search: debouncedSearch,
298
- searchFields: searchFields.length > 0 ? searchFields : undefined,
299
- orderBy: sort.orderBy,
300
- orderDirection: sort.orderDirection,
301
- filters: Object.keys(filters).length > 0 ? filters : undefined,
302
- },
303
- }
304
-
305
- // Main GraphQL query with comprehensive error handling
306
- const { data, loading, error, networkStatus, refetch } = useQuery(query ?? (sdk as any).__AdminUsersDocument, {
307
- variables,
308
- skip: !model || !query,
309
- errorPolicy: 'all', // Continue processing even if there are GraphQL errors
310
- notifyOnNetworkStatusChange: true,
311
- fetchPolicy: 'cache-first',
312
- // Add timeout for network requests
313
- context: {
314
- timeout: 30000, // 30 second timeout
315
- },
316
- })
317
-
318
- // Comprehensive data validation and error handling
319
- const { validatedItems, validatedPagination, dataError } = useMemo(() => {
320
- // Handle GraphQL errors
321
- if (error) {
322
- console.warn('[AdminList] GraphQL error:', error.message)
323
-
324
- // Check for specific error types
325
- const apolloError = error as any
326
- if (apolloError.networkError) {
327
- console.error('[AdminList] Network error:', apolloError.networkError)
328
- }
329
-
330
- if (apolloError.graphQLErrors?.length > 0) {
331
- console.error('[AdminList] GraphQL errors:', apolloError.graphQLErrors)
332
- }
333
- }
334
-
335
- // If no data available, return empty state
336
- if (!data) {
337
- return {
338
- validatedItems: [],
339
- validatedPagination: undefined,
340
- dataError: error || null,
341
- }
342
- }
343
-
344
- try {
345
- const anyData = data as any
346
- let processedItems = dataPath && anyData[dataPath] ? anyData[dataPath] : []
347
- const processedPagination =
348
- paginationPath && anyData[paginationPath] ? anyData[paginationPath] : undefined
349
-
350
- // Fallback: if no items found, try to find array data in the response
351
- if (!processedItems || processedItems.length === 0) {
352
- for (const [key, value] of Object.entries(anyData)) {
353
- if (Array.isArray(value)) {
354
- // If this looks like the right data (first item has an 'id' field), use it
355
- if (value.length > 0 && value[0]?.id) {
356
- processedItems = value
357
- break
358
- }
359
- }
360
- }
361
- }
362
-
363
- // Validate items array
364
- if (!Array.isArray(processedItems)) {
365
- console.warn('[AdminList] Expected array but got:', typeof processedItems)
366
- return {
367
- validatedItems: [],
368
- validatedPagination: processedPagination,
369
- dataError: new Error('Invalid data format: expected array'),
370
- }
371
- }
372
-
373
- // Validate and filter each item
374
- const filteredItems = processedItems.filter((item, index) => {
375
- if (!item || typeof item !== 'object') {
376
- console.warn(`[AdminList] Invalid item at index ${index}:`, item)
377
- return false
378
- }
379
-
380
- if (!item.id) {
381
- console.warn(`[AdminList] Item missing ID at index ${index}:`, item)
382
- return false
383
- }
384
-
385
- return true
386
- })
387
-
388
- return {
389
- validatedItems: filteredItems,
390
- validatedPagination: processedPagination,
391
- dataError: null,
392
- }
393
- } catch (err) {
394
- console.error('[AdminList] Error processing data:', err)
395
- return {
396
- validatedItems: [],
397
- validatedPagination: undefined,
398
- dataError: err instanceof Error ? err : new Error('Unknown data processing error'),
399
- }
400
- }
401
- }, [data, dataPath, paginationPath, error])
402
-
403
- const items = validatedItems
404
- const pagination = validatedPagination
405
-
406
- // Enhanced error display with user-friendly messages and retry options
407
- if (!model) {
408
- return (
409
- <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
410
- <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
411
- <div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
412
- <div className="text-center">
413
- <svg
414
- className="mx-auto h-12 w-12 text-red-400"
415
- fill="none"
416
- stroke="currentColor"
417
- viewBox="0 0 24 24"
418
- >
419
- <path
420
- strokeLinecap="round"
421
- strokeLinejoin="round"
422
- strokeWidth={2}
423
- d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
424
- />
425
- </svg>
426
- <h2 className="mt-4 text-lg font-medium text-gray-900">Invalid Data Type</h2>
427
- <p className="mt-2 text-sm text-gray-600">
428
- The data type "{pluralParam}" is not recognized.
429
- </p>
430
- <div className="mt-6">
431
- <Link
432
- to={basePath}
433
- 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"
434
- >
435
- Return to Data Browser
436
- </Link>
437
- </div>
438
- </div>
439
- </div>
440
- </div>
441
- </div>
442
- )
443
- }
444
-
445
- if (!query) {
446
- return (
447
- <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
448
- <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
449
- <div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
450
- <div className="text-center">
451
- <svg
452
- className="mx-auto h-12 w-12 text-yellow-400"
453
- fill="none"
454
- stroke="currentColor"
455
- viewBox="0 0 24 24"
456
- >
457
- <path
458
- strokeLinecap="round"
459
- strokeLinejoin="round"
460
- strokeWidth={2}
461
- d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L5.316 15c-.77.833.192 2.5 1.732 2.5z"
462
- />
463
- </svg>
464
- <h2 className="mt-4 text-lg font-medium text-gray-900">
465
- GraphQL Schema Not Available
466
- </h2>
467
- <p className="mt-2 text-sm text-gray-600">
468
- No GraphQL query found for "{model.name}". The admin schema may need to be
469
- regenerated.
470
- </p>
471
- <div className="mt-6 space-y-3">
472
- <button
473
- onClick={() => window.location.reload()}
474
- 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"
475
- >
476
- Reload Page
477
- </button>
478
- <Link
479
- to={basePath}
480
- 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"
481
- >
482
- Return to Data Browser
483
- </Link>
484
- </div>
485
- </div>
486
- </div>
487
- </div>
488
- </div>
489
- )
490
- }
491
-
492
- // Calculate create link
493
- const createLink = `${basePath}/${kebabCase(model.name)}/create`
494
-
495
- // Column selector dropdown
496
- const columnSelector = (
497
- <div className="relative" data-dropdown="column-selector">
498
- <button
499
- onClick={() => setShowColumnSelector(!showColumnSelector)}
500
- className="inline-flex items-center px-3 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-web"
501
- >
502
- Columns
503
- <svg
504
- className="-mr-0.5 ml-2 h-4 w-4"
505
- xmlns="http://www.w3.org/2000/svg"
506
- viewBox="0 0 20 20"
507
- fill="currentColor"
508
- >
509
- <path
510
- fillRule="evenodd"
511
- d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
512
- clipRule="evenodd"
513
- />
514
- </svg>
515
- </button>
516
-
517
- {showColumnSelector && (
518
- <div className="absolute right-0 mt-2 w-80 bg-white rounded-md shadow-lg z-50 border border-gray-200">
519
- <div className="p-4">
520
- <h3 className="text-sm font-medium text-gray-900 mb-3">Visible Columns</h3>
521
- <div className="space-y-2 max-h-64 overflow-y-auto">
522
- {fieldNames.map((field: string) => (
523
- <label key={field} className="flex items-start gap-2 cursor-pointer">
524
- <input
525
- type="checkbox"
526
- checked={visibleColumns.includes(field)}
527
- onChange={e => {
528
- const newColumns = e.target.checked
529
- ? [...visibleColumns, field]
530
- : visibleColumns.filter(col => col !== field)
531
- setVisibleColumns(newColumns)
532
- AdminLocalStorage.setColumnVisibility(model.name, newColumns)
533
- }}
534
- className="h-4 w-4 mt-0.5 flex-shrink-0 text-green-web focus:ring-green-web border-gray-300 rounded"
535
- />
536
- <span className="text-sm text-gray-700 leading-5">{formatFieldName(field)}</span>
537
- </label>
538
- ))}
539
- </div>
540
- <div className="mt-3 pt-3 border-t border-gray-200 flex justify-between">
541
- <button
542
- onClick={() => {
543
- setVisibleColumns(fieldNames)
544
- AdminLocalStorage.setColumnVisibility(model.name, fieldNames)
545
- }}
546
- className="text-xs text-green-web hover:text-green-web-800"
547
- >
548
- Select All
549
- </button>
550
- <button
551
- onClick={() => {
552
- const defaults = fieldNames.slice(0, 8)
553
- setVisibleColumns(defaults)
554
- AdminLocalStorage.setColumnVisibility(model.name, defaults)
555
- }}
556
- className="text-xs text-gray-600 hover:text-gray-800"
557
- >
558
- Reset to Defaults
559
- </button>
560
- </div>
561
- </div>
562
- </div>
563
- )}
564
- </div>
565
- )
566
-
567
- // Search field selector dropdown
568
- const searchFieldSelector = (
569
- <div className="relative" data-dropdown="search-field-selector">
570
- <button
571
- onClick={() => setShowSearchFieldSelector(!showSearchFieldSelector)}
572
- className="h-full px-3 border-l border-gray-300 text-gray-400 hover:text-gray-600 focus:outline-none focus:text-gray-600"
573
- aria-label="Configure search fields"
574
- >
575
- <svg
576
- className="h-4 w-4"
577
- xmlns="http://www.w3.org/2000/svg"
578
- viewBox="0 0 20 20"
579
- fill="currentColor"
580
- >
581
- <path
582
- fillRule="evenodd"
583
- d="M3 3a1 1 0 000 2v8a2 2 0 002 2h2.586l-1.293 1.293a1 1 0 101.414 1.414L10 15.414l2.293 2.293a1 1 0 001.414-1.414L12.414 15H15a2 2 0 002-2V5a1 1 0 100-2H3zm11.707 4.707a1 1 0 00-1.414-1.414L10 9.586 8.707 8.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
584
- clipRule="evenodd"
585
- />
586
- </svg>
587
- </button>
588
-
589
- {showSearchFieldSelector && (
590
- <div className="absolute right-0 mt-2 w-72 bg-white rounded-md shadow-lg z-50 border border-gray-200">
591
- <div className="p-4">
592
- <h3 className="text-sm font-medium text-gray-900 mb-3">Search Fields</h3>
593
- {searchableFieldNames.length > 0 ? (
594
- <>
595
- <div className="space-y-2 max-h-48 overflow-y-auto">
596
- {searchableFieldNames.map((field: string) => (
597
- <label key={field} className="flex items-start gap-2 cursor-pointer">
598
- <input
599
- type="checkbox"
600
- checked={searchFields.includes(field)}
601
- onChange={e => {
602
- const newFields = e.target.checked
603
- ? [...searchFields, field]
604
- : searchFields.filter(f => f !== field)
605
- setSearchFields(newFields)
606
- AdminLocalStorage.setSearchFields(model.name, newFields)
607
- }}
608
- className="h-4 w-4 mt-0.5 flex-shrink-0 text-green-web focus:ring-green-web border-gray-300 rounded"
609
- />
610
- <span className="text-sm text-gray-700 leading-5">
611
- {formatFieldName(field)}
612
- </span>
613
- </label>
614
- ))}
615
- </div>
616
- <div className="mt-3 pt-3 border-t border-gray-200 flex justify-between">
617
- <button
618
- onClick={() => {
619
- setSearchFields(searchableFieldNames)
620
- AdminLocalStorage.setSearchFields(model.name, searchableFieldNames)
621
- }}
622
- className="text-xs text-green-web hover:text-green-web-800 mr-3"
623
- >
624
- Select All
625
- </button>
626
- <button
627
- onClick={() => {
628
- const defaults = getDefaultSearchFields(searchableFieldNames)
629
- setSearchFields(defaults)
630
- AdminLocalStorage.setSearchFields(model.name, defaults)
631
- }}
632
- className="text-xs text-gray-600 hover:text-gray-800"
633
- >
634
- Reset to Defaults
635
- </button>
636
- </div>
637
- </>
638
- ) : (
639
- <div className="text-sm text-gray-500">No searchable text fields available</div>
640
- )}
641
- </div>
642
- </div>
643
- )}
644
- </div>
645
- )
646
-
647
- // Filter panel for advanced filtering
648
- const filterPanel = showFilters && (
649
- <div className="mb-4 bg-gray-50 border border-gray-200 rounded-lg p-4">
650
- <div className="flex items-center justify-between mb-3">
651
- <h3 className="text-sm font-medium text-gray-900">Filters</h3>
652
- <div className="flex items-center space-x-2">
653
- <button
654
- onClick={() => {
655
- setFilters({})
656
- dispatch({ type: 'RESET_PAGINATION' })
657
- }}
658
- className="text-xs text-gray-600 hover:text-gray-800"
659
- >
660
- Clear All
661
- </button>
662
- <button
663
- onClick={() => setShowFilters(false)}
664
- className="text-gray-400 hover:text-gray-600"
665
- >
666
- <svg
667
- className="h-5 w-5"
668
- xmlns="http://www.w3.org/2000/svg"
669
- viewBox="0 0 20 20"
670
- fill="currentColor"
671
- >
672
- <path
673
- fillRule="evenodd"
674
- d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
675
- clipRule="evenodd"
676
- />
677
- </svg>
678
- </button>
679
- </div>
680
- </div>
681
-
682
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
683
- {filterableFieldNames.map((fieldName: string) => {
684
- const field = model.fields.find((f: any) => f.name === fieldName)
685
- if (!field) return null
686
-
687
- const currentValue = filters[fieldName]
688
- const handleChange = (value: any) => {
689
- const newFilters = { ...filters }
690
- if (value === undefined || value === null || value === '') {
691
- delete newFilters[fieldName]
692
- } else {
693
- newFilters[fieldName] = value
694
- }
695
- setFilters(newFilters)
696
- dispatch({ type: 'RESET_PAGINATION' })
697
- }
698
-
699
- // Relation field filter
700
- if (field.relationName && !field.isList) {
701
- return (
702
- <RelationFilterField
703
- key={fieldName}
704
- fieldName={fieldName}
705
- relatedModelName={field.type}
706
- currentValue={currentValue}
707
- onChange={handleChange}
708
- />
709
- )
710
- }
711
-
712
- // Date/DateTime filter
713
- if (field.type.toLowerCase() === 'datetime' || field.type.toLowerCase() === 'date') {
714
- return (
715
- <DateRangeFilter
716
- key={fieldName}
717
- fieldName={fieldName}
718
- currentValue={currentValue}
719
- onChange={handleChange}
720
- />
721
- )
722
- }
723
-
724
- // Number range filter
725
- if (['int', 'bigint', 'float', 'decimal'].includes(field.type.toLowerCase())) {
726
- return (
727
- <NumberRangeFilter
728
- key={fieldName}
729
- fieldName={fieldName}
730
- fieldType={field.type.toLowerCase()}
731
- currentValue={currentValue}
732
- onChange={handleChange}
733
- />
734
- )
735
- }
736
-
737
- // Enum field filter
738
- if (field.kind === 'enum') {
739
- const enumValues = getEnumValues(field.type)
740
- if (enumValues) {
741
- return (
742
- <div key={fieldName} className="space-y-1">
743
- <label className="block text-sm font-medium text-gray-700">
744
- {formatFieldName(fieldName)}
745
- </label>
746
- <select
747
- value={typeof currentValue === 'string' ? currentValue : ''}
748
- onChange={e => handleChange(e.target.value || undefined)}
749
- className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-web focus:border-green-web text-sm"
750
- >
751
- <option value="">All</option>
752
- {enumValues.map((val: string) => (
753
- <option key={val} value={val}>
754
- {val}
755
- </option>
756
- ))}
757
- </select>
758
- </div>
759
- )
760
- }
761
- }
762
-
763
- // Boolean filter
764
- if (field.type.toLowerCase() === 'boolean') {
765
- return (
766
- <div key={fieldName} className="space-y-1">
767
- <label className="block text-sm font-medium text-gray-700">
768
- {formatFieldName(fieldName)}
769
- </label>
770
- <select
771
- value={currentValue === undefined || currentValue === null ? '' : currentValue.toString()}
772
- onChange={e => {
773
- const value = e.target.value
774
- handleChange(value === '' ? undefined : value === 'true')
775
- }}
776
- className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-web focus:border-green-web text-sm"
777
- >
778
- <option value="">All</option>
779
- <option value="true">Yes</option>
780
- <option value="false">No</option>
781
- </select>
782
- </div>
783
- )
784
- }
785
-
786
- // String filter (contains)
787
- return (
788
- <div key={fieldName} className="space-y-1">
789
- <label className="block text-sm font-medium text-gray-700">
790
- {formatFieldName(fieldName)}
791
- </label>
792
- <input
793
- type="text"
794
- value={typeof currentValue === 'string' ? currentValue : ''}
795
- onChange={e => handleChange(e.target.value || undefined)}
796
- placeholder={`Filter by ${formatFieldName(fieldName).toLowerCase()}...`}
797
- className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-web focus:border-green-web text-sm"
798
- />
799
- </div>
800
- )
801
- })}
802
- </div>
803
- </div>
804
- )
805
-
806
- const searchFilter = (
807
- <div className="mb-4 flex items-center justify-between">
808
- <div className="flex-1 max-w-lg">
809
- <label htmlFor="search" className="sr-only">
810
- Search
811
- </label>
812
- <div className="relative">
813
- <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
814
- {loading && search !== debouncedSearch ? (
815
- <svg
816
- className="animate-spin h-5 w-5 text-gray-400"
817
- xmlns="http://www.w3.org/2000/svg"
818
- fill="none"
819
- viewBox="0 0 24 24"
820
- >
821
- <circle
822
- className="opacity-25"
823
- cx="12"
824
- cy="12"
825
- r="10"
826
- stroke="currentColor"
827
- strokeWidth="4"
828
- ></circle>
829
- <path
830
- className="opacity-75"
831
- fill="currentColor"
832
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
833
- ></path>
834
- </svg>
835
- ) : (
836
- <svg
837
- className="h-5 w-5 text-gray-400"
838
- xmlns="http://www.w3.org/2000/svg"
839
- viewBox="0 0 20 20"
840
- fill="currentColor"
841
- aria-hidden="true"
842
- >
843
- <path
844
- fillRule="evenodd"
845
- d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
846
- clipRule="evenodd"
847
- />
848
- </svg>
849
- )}
850
- </div>
851
- <input
852
- id="search"
853
- name="search"
854
- className="block w-full pl-10 pr-12 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-green-web focus:border-green-web sm:text-sm"
855
- placeholder={`Search ${searchFields.length > 0 ? searchFields.map((field: string) => field.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (str: string) => str.toUpperCase())).join(', ') : getPluralName(model.name).toLowerCase()}...`}
856
- type="search"
857
- value={state.search || ''}
858
- onChange={e => dispatch({ type: 'SET_SEARCH', payload: e.target.value })}
859
- />
860
- {searchableFieldNames.length > 0 && (
861
- <div className="absolute inset-y-0 right-0 flex items-center">
862
- {searchFieldSelector}
863
- </div>
864
- )}
865
- </div>
866
- </div>
867
- <div className="ml-4 flex space-x-2">
868
- <button
869
- onClick={() => setShowFilters(!showFilters)}
870
- className={`inline-flex items-center px-3 py-2 border text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-web ${
871
- showFilters || Object.keys(filters).length > 0
872
- ? 'border-green-web text-green-web bg-green-50 hover:bg-green-100'
873
- : 'border-gray-300 text-gray-700 bg-white hover:bg-gray-50'
874
- }`}
875
- >
876
- <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
877
- <path
878
- strokeLinecap="round"
879
- strokeLinejoin="round"
880
- strokeWidth={2}
881
- d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
882
- />
883
- </svg>
884
- Filters
885
- {Object.keys(filters).length > 0 && (
886
- <span className="ml-1 inline-flex items-center justify-center px-2 py-1 text-xs font-bold leading-none text-white bg-green-web rounded-full">
887
- {Object.keys(filters).length}
888
- </span>
889
- )}
890
- </button>
891
- {columnSelector}
892
- <Link
893
- to={createLink}
894
- className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-600"
895
- >
896
- Create New
897
- </Link>
898
- </div>
899
- </div>
900
- )
901
-
902
- return (
903
- <>
904
- {filterPanel}
905
- {searchFilter}
906
- <DataTable
907
- data={items}
908
- path={createLink.replace('/create', '')}
909
- fields={visibleColumns.length > 0 ? visibleColumns : fieldNames}
910
- pagination={pagination}
911
- setSkip={(skip: number) => dispatch({ type: 'SET_SKIP', payload: skip })}
912
- sort={state.sort}
913
- setSort={setSortSafely}
914
- />
915
- </>
916
- )
917
- }
918
-
919
- export function AdminDataErrorBoundary({ error }: Readonly<{ error: Error }>) {
920
- return <ErrorBoundary error={error} />
921
- }