@nestledjs/data-browser 0.1.0
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/.babelrc +12 -0
- package/LICENSE +21 -0
- package/README.md +320 -0
- package/eslint.config.mjs +12 -0
- package/package.json +44 -0
- package/project.json +39 -0
- package/src/index.ts +28 -0
- package/src/lib/admin-data.spec.tsx +10 -0
- package/src/lib/admin-data.tsx +9 -0
- package/src/lib/components/filters/DateRangeFilter.tsx +88 -0
- package/src/lib/components/filters/NumberRangeFilter.tsx +111 -0
- package/src/lib/components/filters/RelationComponents.tsx +176 -0
- package/src/lib/components/filters/RelationFilterField.tsx +106 -0
- package/src/lib/components/filters/index.ts +5 -0
- package/src/lib/components/index.ts +2 -0
- package/src/lib/components/shared/AdminBreadcrumbs.tsx +88 -0
- package/src/lib/components/shared/AdminErrorStates.tsx +180 -0
- package/src/lib/components/shared/AdminStatusDisplay.tsx +292 -0
- package/src/lib/components/shared/index.ts +3 -0
- package/src/lib/context/AdminDataContext.tsx +74 -0
- package/src/lib/hooks/useAdminList.ts +42 -0
- package/src/lib/hooks/useClickOutside.ts +21 -0
- package/src/lib/hooks/useDebounce.ts +16 -0
- package/src/lib/hooks/useRelationData.ts +114 -0
- package/src/lib/layouts/AdminDataLayout.tsx +251 -0
- package/src/lib/pages/AdminDataCreatePage.tsx +415 -0
- package/src/lib/pages/AdminDataEditPage.tsx +777 -0
- package/src/lib/pages/AdminDataIndexPage.tsx +50 -0
- package/src/lib/pages/AdminDataListPage.tsx +921 -0
- package/src/lib/types/index.ts +51 -0
- package/src/lib/utils/graphql-utils.ts +538 -0
- package/src/lib/utils/secure-storage.ts +305 -0
- package/src/lib/utils/string-utils.ts +53 -0
- package/tsconfig.json +17 -0
- package/tsconfig.lib.json +20 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
CheckCircleIcon,
|
|
4
|
+
XCircleIcon,
|
|
5
|
+
ExclamationTriangleIcon,
|
|
6
|
+
ClockIcon,
|
|
7
|
+
QuestionMarkCircleIcon,
|
|
8
|
+
PauseCircleIcon,
|
|
9
|
+
} from '@heroicons/react/24/solid'
|
|
10
|
+
|
|
11
|
+
export type StatusType =
|
|
12
|
+
| 'active'
|
|
13
|
+
| 'inactive'
|
|
14
|
+
| 'pending'
|
|
15
|
+
| 'approved'
|
|
16
|
+
| 'rejected'
|
|
17
|
+
| 'warning'
|
|
18
|
+
| 'error'
|
|
19
|
+
| 'success'
|
|
20
|
+
| 'paused'
|
|
21
|
+
| 'unknown'
|
|
22
|
+
|
|
23
|
+
export type StatusSize = 'small' | 'medium' | 'large'
|
|
24
|
+
export type StatusVariant = 'badge' | 'pill' | 'dot' | 'full'
|
|
25
|
+
|
|
26
|
+
export interface AdminStatusDisplayProps {
|
|
27
|
+
readonly status: StatusType
|
|
28
|
+
readonly label?: string
|
|
29
|
+
readonly size?: StatusSize
|
|
30
|
+
readonly variant?: StatusVariant
|
|
31
|
+
readonly showIcon?: boolean
|
|
32
|
+
readonly className?: string
|
|
33
|
+
readonly onClick?: () => void
|
|
34
|
+
readonly tooltip?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AdminUserStatusProps {
|
|
38
|
+
readonly status: 'online' | 'offline' | 'away' | 'busy'
|
|
39
|
+
readonly showLabel?: boolean
|
|
40
|
+
readonly size?: StatusSize
|
|
41
|
+
readonly className?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface StatusConfig {
|
|
45
|
+
icon: React.ComponentType<{ className?: string }>
|
|
46
|
+
bgColor: string
|
|
47
|
+
textColor: string
|
|
48
|
+
borderColor: string
|
|
49
|
+
defaultLabel: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Extract status configuration logic
|
|
53
|
+
const getStatusConfig = (status: StatusType): StatusConfig => {
|
|
54
|
+
const configs: Record<StatusType, StatusConfig> = {
|
|
55
|
+
active: {
|
|
56
|
+
icon: CheckCircleIcon,
|
|
57
|
+
bgColor: 'bg-green-100',
|
|
58
|
+
textColor: 'text-green-800',
|
|
59
|
+
borderColor: 'border-green-200',
|
|
60
|
+
defaultLabel: 'Active',
|
|
61
|
+
},
|
|
62
|
+
inactive: {
|
|
63
|
+
icon: XCircleIcon,
|
|
64
|
+
bgColor: 'bg-gray-100',
|
|
65
|
+
textColor: 'text-gray-800',
|
|
66
|
+
borderColor: 'border-gray-200',
|
|
67
|
+
defaultLabel: 'Inactive',
|
|
68
|
+
},
|
|
69
|
+
pending: {
|
|
70
|
+
icon: ClockIcon,
|
|
71
|
+
bgColor: 'bg-yellow-100',
|
|
72
|
+
textColor: 'text-yellow-800',
|
|
73
|
+
borderColor: 'border-yellow-200',
|
|
74
|
+
defaultLabel: 'Pending',
|
|
75
|
+
},
|
|
76
|
+
approved: {
|
|
77
|
+
icon: CheckCircleIcon,
|
|
78
|
+
bgColor: 'bg-green-100',
|
|
79
|
+
textColor: 'text-green-800',
|
|
80
|
+
borderColor: 'border-green-200',
|
|
81
|
+
defaultLabel: 'Approved',
|
|
82
|
+
},
|
|
83
|
+
rejected: {
|
|
84
|
+
icon: XCircleIcon,
|
|
85
|
+
bgColor: 'bg-red-100',
|
|
86
|
+
textColor: 'text-red-800',
|
|
87
|
+
borderColor: 'border-red-200',
|
|
88
|
+
defaultLabel: 'Rejected',
|
|
89
|
+
},
|
|
90
|
+
warning: {
|
|
91
|
+
icon: ExclamationTriangleIcon,
|
|
92
|
+
bgColor: 'bg-yellow-100',
|
|
93
|
+
textColor: 'text-yellow-800',
|
|
94
|
+
borderColor: 'border-yellow-200',
|
|
95
|
+
defaultLabel: 'Warning',
|
|
96
|
+
},
|
|
97
|
+
error: {
|
|
98
|
+
icon: XCircleIcon,
|
|
99
|
+
bgColor: 'bg-red-100',
|
|
100
|
+
textColor: 'text-red-800',
|
|
101
|
+
borderColor: 'border-red-200',
|
|
102
|
+
defaultLabel: 'Error',
|
|
103
|
+
},
|
|
104
|
+
success: {
|
|
105
|
+
icon: CheckCircleIcon,
|
|
106
|
+
bgColor: 'bg-green-100',
|
|
107
|
+
textColor: 'text-green-800',
|
|
108
|
+
borderColor: 'border-green-200',
|
|
109
|
+
defaultLabel: 'Success',
|
|
110
|
+
},
|
|
111
|
+
paused: {
|
|
112
|
+
icon: PauseCircleIcon,
|
|
113
|
+
bgColor: 'bg-blue-100',
|
|
114
|
+
textColor: 'text-blue-800',
|
|
115
|
+
borderColor: 'border-blue-200',
|
|
116
|
+
defaultLabel: 'Paused',
|
|
117
|
+
},
|
|
118
|
+
unknown: {
|
|
119
|
+
icon: QuestionMarkCircleIcon,
|
|
120
|
+
bgColor: 'bg-gray-100',
|
|
121
|
+
textColor: 'text-gray-800',
|
|
122
|
+
borderColor: 'border-gray-200',
|
|
123
|
+
defaultLabel: 'Unknown',
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return configs[status]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Extract size configuration logic
|
|
131
|
+
const getSizeClasses = (size: StatusSize) => {
|
|
132
|
+
const sizeClasses = {
|
|
133
|
+
small: {
|
|
134
|
+
text: 'text-xs',
|
|
135
|
+
padding: 'px-2 py-1',
|
|
136
|
+
icon: 'h-3 w-3',
|
|
137
|
+
dot: 'h-2 w-2',
|
|
138
|
+
},
|
|
139
|
+
medium: {
|
|
140
|
+
text: 'text-sm',
|
|
141
|
+
padding: 'px-2.5 py-1.5',
|
|
142
|
+
icon: 'h-4 w-4',
|
|
143
|
+
dot: 'h-3 w-3',
|
|
144
|
+
},
|
|
145
|
+
large: {
|
|
146
|
+
text: 'text-base',
|
|
147
|
+
padding: 'px-3 py-2',
|
|
148
|
+
icon: 'h-5 w-5',
|
|
149
|
+
dot: 'h-4 w-4',
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return sizeClasses[size]
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Extract variant styling logic
|
|
157
|
+
const getVariantClasses = (variant: StatusVariant) => {
|
|
158
|
+
const variantClasses = {
|
|
159
|
+
badge: 'inline-flex items-center rounded border font-medium',
|
|
160
|
+
pill: 'inline-flex items-center rounded-full border font-medium',
|
|
161
|
+
dot: 'inline-flex items-center font-medium',
|
|
162
|
+
full: 'flex items-center justify-center rounded border font-medium w-full',
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return variantClasses[variant]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Extract user status configuration logic
|
|
169
|
+
const getUserStatusConfig = (status: AdminUserStatusProps['status']) => {
|
|
170
|
+
const configs = {
|
|
171
|
+
online: {
|
|
172
|
+
color: 'bg-green-400',
|
|
173
|
+
label: 'Online',
|
|
174
|
+
},
|
|
175
|
+
offline: {
|
|
176
|
+
color: 'bg-gray-400',
|
|
177
|
+
label: 'Offline',
|
|
178
|
+
},
|
|
179
|
+
away: {
|
|
180
|
+
color: 'bg-yellow-400',
|
|
181
|
+
label: 'Away',
|
|
182
|
+
},
|
|
183
|
+
busy: {
|
|
184
|
+
color: 'bg-red-400',
|
|
185
|
+
label: 'Busy',
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return configs[status]
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function AdminStatusDisplay({
|
|
193
|
+
status,
|
|
194
|
+
label,
|
|
195
|
+
size = 'medium',
|
|
196
|
+
variant = 'badge',
|
|
197
|
+
showIcon = true,
|
|
198
|
+
className = '',
|
|
199
|
+
onClick,
|
|
200
|
+
tooltip,
|
|
201
|
+
}: Readonly<AdminStatusDisplayProps>) {
|
|
202
|
+
const config = getStatusConfig(status)
|
|
203
|
+
const sizeClasses = getSizeClasses(size)
|
|
204
|
+
const variantClasses = getVariantClasses(variant)
|
|
205
|
+
const IconComponent = config.icon
|
|
206
|
+
|
|
207
|
+
const displayLabel = label || config.defaultLabel
|
|
208
|
+
|
|
209
|
+
const shouldShowDot = variant === 'dot'
|
|
210
|
+
const shouldShowIcon = showIcon && !shouldShowDot
|
|
211
|
+
const isClickable = !!onClick
|
|
212
|
+
|
|
213
|
+
const baseClasses = `${variantClasses} ${sizeClasses.text} ${sizeClasses.padding} ${config.bgColor} ${config.textColor} ${config.borderColor}`
|
|
214
|
+
const interactiveClasses = isClickable ? 'cursor-pointer hover:opacity-80' : ''
|
|
215
|
+
const finalClasses = `${baseClasses} ${interactiveClasses} ${className}`.trim()
|
|
216
|
+
|
|
217
|
+
const renderDot = () => {
|
|
218
|
+
if (!shouldShowDot) return null
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<span className={`inline-block rounded-full mr-2 ${config.bgColor.replace('bg-', 'bg-').replace('-100', '-400')} ${sizeClasses.dot}`} />
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const renderIcon = () => {
|
|
226
|
+
if (!shouldShowIcon) return null
|
|
227
|
+
|
|
228
|
+
return (
|
|
229
|
+
<IconComponent className={`${sizeClasses.icon} ${displayLabel ? 'mr-1.5' : ''}`} />
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const content = (
|
|
234
|
+
<>
|
|
235
|
+
{renderDot()}
|
|
236
|
+
{renderIcon()}
|
|
237
|
+
{displayLabel}
|
|
238
|
+
</>
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
const handleClick = () => {
|
|
242
|
+
if (onClick) {
|
|
243
|
+
onClick()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (isClickable) {
|
|
248
|
+
return (
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
onClick={handleClick}
|
|
252
|
+
className={finalClasses}
|
|
253
|
+
title={tooltip}
|
|
254
|
+
>
|
|
255
|
+
{content}
|
|
256
|
+
</button>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<span className={finalClasses} title={tooltip}>
|
|
262
|
+
{content}
|
|
263
|
+
</span>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function AdminUserStatus({
|
|
268
|
+
status,
|
|
269
|
+
showLabel = false,
|
|
270
|
+
size = 'medium',
|
|
271
|
+
className = '',
|
|
272
|
+
}: Readonly<AdminUserStatusProps>) {
|
|
273
|
+
const config = getUserStatusConfig(status)
|
|
274
|
+
const sizeClasses = getSizeClasses(size)
|
|
275
|
+
|
|
276
|
+
const dotSizeClass = sizeClasses.dot
|
|
277
|
+
const textSizeClass = showLabel ? sizeClasses.text : ''
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<div className={`inline-flex items-center ${className}`}>
|
|
281
|
+
<span
|
|
282
|
+
className={`inline-block rounded-full ${config.color} ${dotSizeClass}`}
|
|
283
|
+
aria-label={config.label}
|
|
284
|
+
/>
|
|
285
|
+
{showLabel && (
|
|
286
|
+
<span className={`ml-2 text-gray-700 ${textSizeClass}`}>
|
|
287
|
+
{config.label}
|
|
288
|
+
</span>
|
|
289
|
+
)}
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface AdminDataContextValue {
|
|
4
|
+
/** The SDK namespace for dynamic GraphQL document lookups */
|
|
5
|
+
sdk: any
|
|
6
|
+
/** Array of database models from Prisma schema */
|
|
7
|
+
databaseModels: any[]
|
|
8
|
+
/** Base path for admin data routes (e.g., "/admin/data") */
|
|
9
|
+
basePath?: string
|
|
10
|
+
/** Form theme configuration for @nestledjs/forms */
|
|
11
|
+
formTheme: any
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const AdminDataContext = createContext<AdminDataContextValue | null>(null)
|
|
15
|
+
|
|
16
|
+
export interface AdminDataProviderProps {
|
|
17
|
+
children: ReactNode
|
|
18
|
+
/** The entire SDK namespace (e.g., import * as Sdk from '@your-project/shared/sdk') */
|
|
19
|
+
sdk: any
|
|
20
|
+
/** DATABASE_MODELS array from your SDK */
|
|
21
|
+
databaseModels: any[]
|
|
22
|
+
/** Optional base path for routes (defaults to "/admin/data") */
|
|
23
|
+
basePath?: string
|
|
24
|
+
/** Form theme configuration for @nestledjs/forms */
|
|
25
|
+
formTheme: any
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Provider component that supplies SDK and database models to admin data components
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```tsx
|
|
33
|
+
* import * as Sdk from '@your-project/shared/sdk'
|
|
34
|
+
* import { DATABASE_MODELS } from '@your-project/shared/sdk'
|
|
35
|
+
* import { AdminDataProvider } from '@nestledjs/admin-data'
|
|
36
|
+
*
|
|
37
|
+
* <AdminDataProvider
|
|
38
|
+
* sdk={Sdk}
|
|
39
|
+
* databaseModels={DATABASE_MODELS}
|
|
40
|
+
* formTheme={formTheme}
|
|
41
|
+
* basePath="/admin/data"
|
|
42
|
+
* >
|
|
43
|
+
* <AdminDataLayout />
|
|
44
|
+
* </AdminDataProvider>
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function AdminDataProvider({
|
|
48
|
+
children,
|
|
49
|
+
sdk,
|
|
50
|
+
databaseModels,
|
|
51
|
+
basePath = '/admin/data',
|
|
52
|
+
formTheme
|
|
53
|
+
}: AdminDataProviderProps) {
|
|
54
|
+
return (
|
|
55
|
+
<AdminDataContext.Provider value={{ sdk, databaseModels, basePath, formTheme }}>
|
|
56
|
+
{children}
|
|
57
|
+
</AdminDataContext.Provider>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Hook to access the admin data context
|
|
63
|
+
* @throws Error if used outside of AdminDataProvider
|
|
64
|
+
*/
|
|
65
|
+
export function useAdminDataContext() {
|
|
66
|
+
const context = useContext(AdminDataContext)
|
|
67
|
+
if (!context) {
|
|
68
|
+
throw new Error(
|
|
69
|
+
'useAdminDataContext must be used within AdminDataProvider. ' +
|
|
70
|
+
'Make sure to wrap your admin data routes with <AdminDataProvider>.'
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
return context
|
|
74
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useReducer } from 'react'
|
|
2
|
+
import { AdminListAction, AdminListState, initialState } from '../types'
|
|
3
|
+
|
|
4
|
+
// State reducer for performance optimization
|
|
5
|
+
function adminListReducer(state: AdminListState, action: AdminListAction): AdminListState {
|
|
6
|
+
switch (action.type) {
|
|
7
|
+
case 'SET_SEARCH':
|
|
8
|
+
return { ...state, search: action.payload }
|
|
9
|
+
case 'SET_DEBOUNCED_SEARCH':
|
|
10
|
+
return { ...state, debouncedSearch: action.payload }
|
|
11
|
+
case 'SET_SKIP':
|
|
12
|
+
return { ...state, skip: action.payload }
|
|
13
|
+
case 'SET_PAGE_SIZE':
|
|
14
|
+
return { ...state, pageSize: action.payload }
|
|
15
|
+
case 'SET_SORT':
|
|
16
|
+
return { ...state, sort: action.payload }
|
|
17
|
+
case 'SET_VISIBLE_COLUMNS':
|
|
18
|
+
return { ...state, visibleColumns: action.payload }
|
|
19
|
+
case 'TOGGLE_COLUMN_SELECTOR':
|
|
20
|
+
return { ...state, showColumnSelector: !state.showColumnSelector }
|
|
21
|
+
case 'SET_SEARCH_FIELDS':
|
|
22
|
+
return { ...state, searchFields: action.payload }
|
|
23
|
+
case 'TOGGLE_SEARCH_FIELD_SELECTOR':
|
|
24
|
+
return { ...state, showSearchFieldSelector: !state.showSearchFieldSelector }
|
|
25
|
+
case 'SET_FILTERS':
|
|
26
|
+
return { ...state, filters: action.payload }
|
|
27
|
+
case 'TOGGLE_FILTERS':
|
|
28
|
+
return { ...state, showFilters: !state.showFilters }
|
|
29
|
+
case 'RESET_PAGINATION':
|
|
30
|
+
return { ...state, skip: 0 }
|
|
31
|
+
case 'RESET_FILTERS':
|
|
32
|
+
return { ...state, filters: {} }
|
|
33
|
+
default:
|
|
34
|
+
return state
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useAdminList() {
|
|
39
|
+
const [state, dispatch] = useReducer(adminListReducer, initialState)
|
|
40
|
+
|
|
41
|
+
return { state, dispatch }
|
|
42
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React, { useEffect } from 'react'
|
|
2
|
+
|
|
3
|
+
// Custom hook for click outside detection
|
|
4
|
+
export function useClickOutside(
|
|
5
|
+
ref: React.RefObject<HTMLDivElement | null>,
|
|
6
|
+
handler: () => void,
|
|
7
|
+
isActive = true
|
|
8
|
+
) {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!isActive) return
|
|
11
|
+
|
|
12
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
13
|
+
if (ref.current && !ref.current.contains(event.target as Node)) {
|
|
14
|
+
handler()
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
19
|
+
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
20
|
+
}, [handler, isActive]) // Removed 'ref' from dependency array as refs are stable
|
|
21
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
// Custom hook for debouncing values
|
|
4
|
+
export function useDebounce<T>(value: T, delay: number): T {
|
|
5
|
+
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const timer = setTimeout(() => {
|
|
9
|
+
setDebouncedValue(value)
|
|
10
|
+
}, delay)
|
|
11
|
+
|
|
12
|
+
return () => clearTimeout(timer)
|
|
13
|
+
}, [value, delay])
|
|
14
|
+
|
|
15
|
+
return debouncedValue
|
|
16
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useQuery } from '@apollo/client/react'
|
|
2
|
+
import { useMemo } from 'react'
|
|
3
|
+
import { useAdminDataContext } from '../context/AdminDataContext'
|
|
4
|
+
import { getAdminDocuments } from '../utils/graphql-utils'
|
|
5
|
+
import { getSmartSearchFields } from '../utils/string-utils'
|
|
6
|
+
import { useDebounce } from './useDebounce'
|
|
7
|
+
|
|
8
|
+
// Custom hook for relation data fetching and management
|
|
9
|
+
export function useRelationData(relatedModelName: string, searchTerm: string, isOpen: boolean) {
|
|
10
|
+
const { sdk, databaseModels } = useAdminDataContext()
|
|
11
|
+
const debouncedSearchTerm = useDebounce(searchTerm, 300)
|
|
12
|
+
|
|
13
|
+
// Get the related model and its GraphQL documents
|
|
14
|
+
const relatedModel = useMemo(
|
|
15
|
+
() => databaseModels.find((m: any) => m.name === relatedModelName),
|
|
16
|
+
[databaseModels, relatedModelName],
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const relatedDocuments = useMemo(
|
|
20
|
+
() => (relatedModel ? getAdminDocuments(sdk, relatedModel) : { listQuery: undefined }),
|
|
21
|
+
[sdk, relatedModel],
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const relatedDataPath = useMemo(
|
|
25
|
+
() =>
|
|
26
|
+
relatedModel?.pluralModelPropertyName ||
|
|
27
|
+
relatedModelName.charAt(0).toLowerCase() + relatedModelName.slice(1) + 's',
|
|
28
|
+
[relatedModel, relatedModelName],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
// Get searchable fields for the related model
|
|
32
|
+
const searchableFields = useMemo(() => {
|
|
33
|
+
if (!relatedModel) return []
|
|
34
|
+
|
|
35
|
+
return relatedModel.fields
|
|
36
|
+
.filter((f: any) => {
|
|
37
|
+
const fieldType = f.type.toLowerCase()
|
|
38
|
+
return (
|
|
39
|
+
!f.relationName &&
|
|
40
|
+
(fieldType === 'string' || fieldType.includes('text') || fieldType === 'boolean')
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
.map((f: any) => f.name)
|
|
44
|
+
}, [relatedModel])
|
|
45
|
+
|
|
46
|
+
const searchFields = useMemo(() => getSmartSearchFields(searchableFields), [searchableFields])
|
|
47
|
+
|
|
48
|
+
// Query variables for relation search
|
|
49
|
+
const queryVariables = useMemo(
|
|
50
|
+
() => ({
|
|
51
|
+
input: {
|
|
52
|
+
take: 20,
|
|
53
|
+
...(debouncedSearchTerm.trim()
|
|
54
|
+
? {
|
|
55
|
+
search: debouncedSearchTerm,
|
|
56
|
+
searchFields: searchFields.length > 0 ? searchFields : undefined,
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
[debouncedSearchTerm, searchFields],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
// Query for related items with comprehensive error handling
|
|
65
|
+
const {
|
|
66
|
+
data: relatedData,
|
|
67
|
+
loading,
|
|
68
|
+
error: relationError,
|
|
69
|
+
} = useQuery(relatedDocuments.listQuery, {
|
|
70
|
+
variables: queryVariables,
|
|
71
|
+
skip: !relatedDocuments.listQuery || !isOpen,
|
|
72
|
+
errorPolicy: 'all', // Continue processing even if there are GraphQL errors
|
|
73
|
+
notifyOnNetworkStatusChange: true,
|
|
74
|
+
fetchPolicy: 'cache-first', // Use cache for better performance
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// Validate and sanitize relation data
|
|
78
|
+
const relatedItems = useMemo(() => {
|
|
79
|
+
if (relationError) {
|
|
80
|
+
console.warn('[RelationData] GraphQL error:', relationError.message)
|
|
81
|
+
return []
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!relatedData) return []
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const items = (relatedData as any)[relatedDataPath] || []
|
|
88
|
+
|
|
89
|
+
// Validate that items is an array
|
|
90
|
+
if (!Array.isArray(items)) {
|
|
91
|
+
console.warn('[RelationData] Expected array but got:', typeof items)
|
|
92
|
+
return []
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Validate each item has required properties
|
|
96
|
+
return items.filter(item => {
|
|
97
|
+
if (!item || typeof item !== 'object') return false
|
|
98
|
+
if (!item.id) return false // Require ID field
|
|
99
|
+
return true
|
|
100
|
+
})
|
|
101
|
+
} catch (error) {
|
|
102
|
+
console.error('[RelationData] Error processing relation data:', error)
|
|
103
|
+
return []
|
|
104
|
+
}
|
|
105
|
+
}, [relatedData, relatedDataPath, relationError])
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
relatedModel,
|
|
109
|
+
relatedItems,
|
|
110
|
+
loading,
|
|
111
|
+
error: relationError,
|
|
112
|
+
hasDocument: !!relatedDocuments.listQuery,
|
|
113
|
+
}
|
|
114
|
+
}
|