@nestledjs/data-browser 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.d.ts +15 -0
- package/index.js +64 -0
- package/lib/admin-data.d.ts +2 -0
- package/lib/components/filters/DateRangeFilter.d.ts +7 -0
- package/lib/components/filters/DateRangeFilter.js +105 -0
- package/lib/components/filters/NumberRangeFilter.d.ts +8 -0
- package/lib/components/filters/NumberRangeFilter.js +115 -0
- package/lib/components/filters/RelationComponents.d.ts +32 -0
- package/lib/components/filters/RelationComponents.js +265 -0
- package/lib/components/filters/RelationFilterField.d.ts +8 -0
- package/lib/components/filters/RelationFilterField.js +116 -0
- package/lib/components/filters/index.d.ts +4 -0
- package/lib/components/index.d.ts +2 -0
- package/lib/components/shared/AdminBreadcrumbs.d.ts +13 -0
- package/lib/components/shared/AdminBreadcrumbs.js +103 -0
- package/lib/components/shared/AdminErrorStates.d.ts +30 -0
- package/lib/components/shared/AdminErrorStates.js +214 -0
- package/lib/components/shared/AdminStatusDisplay.d.ts +21 -0
- package/lib/components/shared/AdminStatusDisplay.js +205 -0
- package/lib/components/shared/index.d.ts +3 -0
- package/lib/context/AdminDataContext.d.ts +47 -0
- package/lib/context/AdminDataContext.js +28 -0
- package/lib/hooks/useAdminList.d.ts +5 -0
- package/lib/hooks/useAdminList.js +41 -0
- package/lib/hooks/useClickOutside.d.ts +2 -0
- package/lib/hooks/useClickOutside.js +13 -0
- package/lib/hooks/useDebounce.d.ts +1 -0
- package/lib/hooks/useDebounce.js +13 -0
- package/lib/hooks/useRelationData.d.ts +7 -0
- package/lib/hooks/useRelationData.js +64 -0
- package/lib/layouts/AdminDataLayout.d.ts +5 -0
- package/lib/layouts/AdminDataLayout.js +315 -0
- package/lib/pages/AdminDataCreatePage.d.ts +4 -0
- package/lib/pages/AdminDataCreatePage.js +487 -0
- package/lib/pages/AdminDataEditPage.d.ts +4 -0
- package/lib/pages/AdminDataEditPage.js +903 -0
- package/lib/pages/AdminDataIndexPage.d.ts +4 -0
- package/lib/pages/AdminDataIndexPage.js +91 -0
- package/lib/pages/AdminDataListPage.d.ts +9 -0
- package/lib/pages/AdminDataListPage.js +1218 -0
- package/lib/types/index.d.ts +60 -0
- package/lib/types/index.js +16 -0
- package/lib/utils/graphql-utils.d.ts +25 -0
- package/lib/utils/graphql-utils.js +265 -0
- package/lib/utils/secure-storage.d.ts +50 -0
- package/lib/utils/secure-storage.js +145 -0
- package/lib/utils/string-utils.d.ts +6 -0
- package/lib/utils/string-utils.js +27 -0
- package/package.json +8 -2
- package/.babelrc +0 -12
- package/LICENSE +0 -21
- package/eslint.config.mjs +0 -12
- package/project.json +0 -35
- package/src/index.ts +0 -28
- package/src/lib/admin-data.spec.tsx +0 -10
- package/src/lib/admin-data.tsx +0 -9
- package/src/lib/components/filters/DateRangeFilter.tsx +0 -88
- package/src/lib/components/filters/NumberRangeFilter.tsx +0 -111
- package/src/lib/components/filters/RelationComponents.tsx +0 -176
- package/src/lib/components/filters/RelationFilterField.tsx +0 -106
- package/src/lib/components/filters/index.ts +0 -5
- package/src/lib/components/index.ts +0 -2
- package/src/lib/components/shared/AdminBreadcrumbs.tsx +0 -88
- package/src/lib/components/shared/AdminErrorStates.tsx +0 -180
- package/src/lib/components/shared/AdminStatusDisplay.tsx +0 -292
- package/src/lib/components/shared/index.ts +0 -3
- package/src/lib/context/AdminDataContext.tsx +0 -74
- package/src/lib/hooks/useAdminList.ts +0 -42
- package/src/lib/hooks/useClickOutside.ts +0 -21
- package/src/lib/hooks/useDebounce.ts +0 -16
- package/src/lib/hooks/useRelationData.ts +0 -114
- package/src/lib/layouts/AdminDataLayout.tsx +0 -251
- package/src/lib/pages/AdminDataCreatePage.tsx +0 -415
- package/src/lib/pages/AdminDataEditPage.tsx +0 -777
- package/src/lib/pages/AdminDataIndexPage.tsx +0 -50
- package/src/lib/pages/AdminDataListPage.tsx +0 -921
- package/src/lib/types/index.ts +0 -51
- package/src/lib/utils/graphql-utils.ts +0 -538
- package/src/lib/utils/secure-storage.ts +0 -305
- package/src/lib/utils/string-utils.ts +0 -53
- package/tsconfig.json +0 -17
- package/tsconfig.lib.json +0 -20
- package/vite.config.ts +0 -35
|
@@ -1,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
|
-
}
|