@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.
Files changed (35) hide show
  1. package/.babelrc +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +320 -0
  4. package/eslint.config.mjs +12 -0
  5. package/package.json +44 -0
  6. package/project.json +39 -0
  7. package/src/index.ts +28 -0
  8. package/src/lib/admin-data.spec.tsx +10 -0
  9. package/src/lib/admin-data.tsx +9 -0
  10. package/src/lib/components/filters/DateRangeFilter.tsx +88 -0
  11. package/src/lib/components/filters/NumberRangeFilter.tsx +111 -0
  12. package/src/lib/components/filters/RelationComponents.tsx +176 -0
  13. package/src/lib/components/filters/RelationFilterField.tsx +106 -0
  14. package/src/lib/components/filters/index.ts +5 -0
  15. package/src/lib/components/index.ts +2 -0
  16. package/src/lib/components/shared/AdminBreadcrumbs.tsx +88 -0
  17. package/src/lib/components/shared/AdminErrorStates.tsx +180 -0
  18. package/src/lib/components/shared/AdminStatusDisplay.tsx +292 -0
  19. package/src/lib/components/shared/index.ts +3 -0
  20. package/src/lib/context/AdminDataContext.tsx +74 -0
  21. package/src/lib/hooks/useAdminList.ts +42 -0
  22. package/src/lib/hooks/useClickOutside.ts +21 -0
  23. package/src/lib/hooks/useDebounce.ts +16 -0
  24. package/src/lib/hooks/useRelationData.ts +114 -0
  25. package/src/lib/layouts/AdminDataLayout.tsx +251 -0
  26. package/src/lib/pages/AdminDataCreatePage.tsx +415 -0
  27. package/src/lib/pages/AdminDataEditPage.tsx +777 -0
  28. package/src/lib/pages/AdminDataIndexPage.tsx +50 -0
  29. package/src/lib/pages/AdminDataListPage.tsx +921 -0
  30. package/src/lib/types/index.ts +51 -0
  31. package/src/lib/utils/graphql-utils.ts +538 -0
  32. package/src/lib/utils/secure-storage.ts +305 -0
  33. package/src/lib/utils/string-utils.ts +53 -0
  34. package/tsconfig.json +17 -0
  35. 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,3 @@
1
+ export * from './AdminBreadcrumbs'
2
+ export * from './AdminErrorStates'
3
+ export * from './AdminStatusDisplay'
@@ -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
+ }