@contember/bindx-react 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/dist/batching/QueryBatcher.d.ts +31 -0
- package/dist/batching/QueryBatcher.d.ts.map +1 -0
- package/dist/batching/index.d.ts +2 -0
- package/dist/batching/index.d.ts.map +1 -0
- package/dist/components/EntityScope.d.ts +28 -0
- package/dist/components/EntityScope.d.ts.map +1 -0
- package/dist/dataview/DataGrid.d.ts +45 -0
- package/dist/dataview/DataGrid.d.ts.map +1 -0
- package/dist/dataview/DataGridPagination.d.ts +24 -0
- package/dist/dataview/DataGridPagination.d.ts.map +1 -0
- package/dist/dataview/DataGridToolbar.d.ts +16 -0
- package/dist/dataview/DataGridToolbar.d.ts.map +1 -0
- package/dist/dataview/DataViewContext.d.ts +32 -0
- package/dist/dataview/DataViewContext.d.ts.map +1 -0
- package/dist/dataview/columns.d.ts +115 -0
- package/dist/dataview/columns.d.ts.map +1 -0
- package/dist/dataview/contextHooks.d.ts +48 -0
- package/dist/dataview/contextHooks.d.ts.map +1 -0
- package/dist/dataview/dataAttribute.d.ts +6 -0
- package/dist/dataview/dataAttribute.d.ts.map +1 -0
- package/dist/dataview/export.d.ts +46 -0
- package/dist/dataview/export.d.ts.map +1 -0
- package/dist/dataview/filterComponents.d.ts +127 -0
- package/dist/dataview/filterComponents.d.ts.map +1 -0
- package/dist/dataview/filterContext.d.ts +10 -0
- package/dist/dataview/filterContext.d.ts.map +1 -0
- package/dist/dataview/filterHooks.d.ts +74 -0
- package/dist/dataview/filterHooks.d.ts.map +1 -0
- package/dist/dataview/filterScope.d.ts +54 -0
- package/dist/dataview/filterScope.d.ts.map +1 -0
- package/dist/dataview/filterWrappers.d.ts +65 -0
- package/dist/dataview/filterWrappers.d.ts.map +1 -0
- package/dist/dataview/index.d.ts +21 -0
- package/dist/dataview/index.d.ts.map +1 -0
- package/dist/dataview/infiniteLoad.d.ts +32 -0
- package/dist/dataview/infiniteLoad.d.ts.map +1 -0
- package/dist/dataview/loaderComponents.d.ts +80 -0
- package/dist/dataview/loaderComponents.d.ts.map +1 -0
- package/dist/dataview/pagingComponents.d.ts +50 -0
- package/dist/dataview/pagingComponents.d.ts.map +1 -0
- package/dist/dataview/renderCellValue.d.ts +9 -0
- package/dist/dataview/renderCellValue.d.ts.map +1 -0
- package/dist/dataview/selectionComponents.d.ts +62 -0
- package/dist/dataview/selectionComponents.d.ts.map +1 -0
- package/dist/dataview/sortingComponents.d.ts +39 -0
- package/dist/dataview/sortingComponents.d.ts.map +1 -0
- package/dist/dataview/stateStorage.d.ts +19 -0
- package/dist/dataview/stateStorage.d.ts.map +1 -0
- package/dist/dataview/useDataView.d.ts +58 -0
- package/dist/dataview/useDataView.d.ts.map +1 -0
- package/dist/dataview/useDataViewState.d.ts +89 -0
- package/dist/dataview/useDataViewState.d.ts.map +1 -0
- package/dist/hooks/BackendAdapterContext.d.ts +110 -0
- package/dist/hooks/BackendAdapterContext.d.ts.map +1 -0
- package/dist/hooks/ContemberBindxProvider.d.ts +67 -0
- package/dist/hooks/ContemberBindxProvider.d.ts.map +1 -0
- package/dist/hooks/createBindx.d.ts +39 -0
- package/dist/hooks/createBindx.d.ts.map +1 -0
- package/dist/hooks/index.d.ts +10 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/useBindxEvents.d.ts +83 -0
- package/dist/hooks/useBindxEvents.d.ts.map +1 -0
- package/dist/hooks/useEntity.d.ts +123 -0
- package/dist/hooks/useEntity.d.ts.map +1 -0
- package/dist/hooks/useEntityBeforePersist.d.ts +22 -0
- package/dist/hooks/useEntityBeforePersist.d.ts.map +1 -0
- package/dist/hooks/useEntityCore.d.ts +52 -0
- package/dist/hooks/useEntityCore.d.ts.map +1 -0
- package/dist/hooks/useEntityImpl.d.ts +105 -0
- package/dist/hooks/useEntityImpl.d.ts.map +1 -0
- package/dist/hooks/useEntityList.d.ts +102 -0
- package/dist/hooks/useEntityList.d.ts.map +1 -0
- package/dist/hooks/useEntityListCore.d.ts +49 -0
- package/dist/hooks/useEntityListCore.d.ts.map +1 -0
- package/dist/hooks/useEntityListImpl.d.ts +70 -0
- package/dist/hooks/useEntityListImpl.d.ts.map +1 -0
- package/dist/hooks/useErrors.d.ts +72 -0
- package/dist/hooks/useErrors.d.ts.map +1 -0
- package/dist/hooks/usePersist.d.ts +115 -0
- package/dist/hooks/usePersist.d.ts.map +1 -0
- package/dist/hooks/useSelectionCollection.d.ts +41 -0
- package/dist/hooks/useSelectionCollection.d.ts.map +1 -0
- package/dist/hooks/useSelectionCollectionForList.d.ts +40 -0
- package/dist/hooks/useSelectionCollectionForList.d.ts.map +1 -0
- package/dist/hooks/useStableSelectionMeta.d.ts +11 -0
- package/dist/hooks/useStableSelectionMeta.d.ts.map +1 -0
- package/dist/hooks/useStoreSubscription.d.ts +19 -0
- package/dist/hooks/useStoreSubscription.d.ts.map +1 -0
- package/dist/hooks/useUndo.d.ts +45 -0
- package/dist/hooks/useUndo.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/jsx/SelectionMeta.d.ts +2 -0
- package/dist/jsx/SelectionMeta.d.ts.map +1 -0
- package/dist/jsx/analyzer.d.ts +22 -0
- package/dist/jsx/analyzer.d.ts.map +1 -0
- package/dist/jsx/collectorProxy.d.ts +14 -0
- package/dist/jsx/collectorProxy.d.ts.map +1 -0
- package/dist/jsx/componentBuilder.d.ts +51 -0
- package/dist/jsx/componentBuilder.d.ts.map +1 -0
- package/dist/jsx/componentBuilder.types.d.ts +268 -0
- package/dist/jsx/componentBuilder.types.d.ts.map +1 -0
- package/dist/jsx/componentBuilderCompat.d.ts +17 -0
- package/dist/jsx/componentBuilderCompat.d.ts.map +1 -0
- package/dist/jsx/componentFactory.d.ts +39 -0
- package/dist/jsx/componentFactory.d.ts.map +1 -0
- package/dist/jsx/components/Entity.d.ts +82 -0
- package/dist/jsx/components/Entity.d.ts.map +1 -0
- package/dist/jsx/components/EntityList.d.ts +48 -0
- package/dist/jsx/components/EntityList.d.ts.map +1 -0
- package/dist/jsx/components/Field.d.ts +27 -0
- package/dist/jsx/components/Field.d.ts.map +1 -0
- package/dist/jsx/components/HasMany.d.ts +34 -0
- package/dist/jsx/components/HasMany.d.ts.map +1 -0
- package/dist/jsx/components/HasOne.d.ts +25 -0
- package/dist/jsx/components/HasOne.d.ts.map +1 -0
- package/dist/jsx/components/HasRole.d.ts +51 -0
- package/dist/jsx/components/HasRole.d.ts.map +1 -0
- package/dist/jsx/components/If.d.ts +51 -0
- package/dist/jsx/components/If.d.ts.map +1 -0
- package/dist/jsx/components/Show.d.ts +33 -0
- package/dist/jsx/components/Show.d.ts.map +1 -0
- package/dist/jsx/components/index.d.ts +8 -0
- package/dist/jsx/components/index.d.ts.map +1 -0
- package/dist/jsx/conditions.d.ts +310 -0
- package/dist/jsx/conditions.d.ts.map +1 -0
- package/dist/jsx/createComponent.d.ts +44 -0
- package/dist/jsx/createComponent.d.ts.map +1 -0
- package/dist/jsx/index.d.ts +19 -0
- package/dist/jsx/index.d.ts.map +1 -0
- package/dist/jsx/inlineProxy.d.ts +14 -0
- package/dist/jsx/inlineProxy.d.ts.map +1 -0
- package/dist/jsx/legacyTypes.d.ts +26 -0
- package/dist/jsx/legacyTypes.d.ts.map +1 -0
- package/dist/jsx/proxy.d.ts +2 -0
- package/dist/jsx/proxy.d.ts.map +1 -0
- package/dist/jsx/proxyShared.d.ts +14 -0
- package/dist/jsx/proxyShared.d.ts.map +1 -0
- package/dist/jsx/runtimeProxy.d.ts +11 -0
- package/dist/jsx/runtimeProxy.d.ts.map +1 -0
- package/dist/jsx/standaloneCreateComponent.d.ts +30 -0
- package/dist/jsx/standaloneCreateComponent.d.ts.map +1 -0
- package/dist/jsx/types.d.ts +109 -0
- package/dist/jsx/types.d.ts.map +1 -0
- package/dist/jsx/withCollector.d.ts +39 -0
- package/dist/jsx/withCollector.d.ts.map +1 -0
- package/dist/roles/RoleContext.d.ts +31 -0
- package/dist/roles/RoleContext.d.ts.map +1 -0
- package/dist/roles/index.d.ts +2 -0
- package/dist/roles/index.d.ts.map +1 -0
- package/package.json +34 -0
- package/src/batching/QueryBatcher.ts +114 -0
- package/src/batching/index.ts +1 -0
- package/src/components/EntityScope.tsx +43 -0
- package/src/hooks/BackendAdapterContext.tsx +214 -0
- package/src/hooks/ContemberBindxProvider.tsx +158 -0
- package/src/hooks/index.ts +61 -0
- package/src/hooks/useBindxEvents.ts +229 -0
- package/src/hooks/useEntity.ts +489 -0
- package/src/hooks/useEntityBeforePersist.ts +32 -0
- package/src/hooks/useEntityList.ts +444 -0
- package/src/hooks/useErrors.ts +150 -0
- package/src/hooks/usePersist.ts +377 -0
- package/src/hooks/useSelectionCollection.ts +122 -0
- package/src/hooks/useStoreSubscription.ts +51 -0
- package/src/hooks/useUndo.ts +83 -0
- package/src/index.ts +357 -0
- package/src/jsx/SelectionMeta.ts +2 -0
- package/src/jsx/analyzer.ts +261 -0
- package/src/jsx/collectorProxy.ts +282 -0
- package/src/jsx/componentBuilder.ts +180 -0
- package/src/jsx/componentBuilder.types.ts +463 -0
- package/src/jsx/componentBuilderCompat.ts +39 -0
- package/src/jsx/componentFactory.ts +400 -0
- package/src/jsx/components/Entity.tsx +383 -0
- package/src/jsx/components/EntityList.tsx +147 -0
- package/src/jsx/components/Field.tsx +79 -0
- package/src/jsx/components/HasMany.tsx +109 -0
- package/src/jsx/components/HasOne.tsx +78 -0
- package/src/jsx/components/HasRole.tsx +95 -0
- package/src/jsx/components/If.tsx +127 -0
- package/src/jsx/components/Show.tsx +56 -0
- package/src/jsx/components/index.ts +20 -0
- package/src/jsx/conditions.ts +472 -0
- package/src/jsx/createComponent.ts +175 -0
- package/src/jsx/index.ts +135 -0
- package/src/jsx/legacyTypes.ts +30 -0
- package/src/jsx/proxy.ts +1 -0
- package/src/jsx/proxyShared.ts +42 -0
- package/src/jsx/standaloneCreateComponent.ts +47 -0
- package/src/jsx/types.ts +192 -0
- package/src/jsx/withCollector.ts +45 -0
- package/src/roles/RoleContext.tsx +57 -0
- package/src/roles/index.ts +1 -0
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { useRef, useEffect, useMemo, useCallback } from 'react'
|
|
2
|
+
import type { EntityDef, EntityAccessor, SelectionInput, SelectionMeta, FieldError, SchemaRegistry, CommonEntity, EntityForRoles, RoleNames } from '@contember/bindx'
|
|
3
|
+
import { EntityHandle, isTempId, resolveSelectionMeta, buildQueryFromSelection, setEntityData, createLoadError } from '@contember/bindx'
|
|
4
|
+
import { useBindxContext, useSchemaRegistry } from './BackendAdapterContext.js'
|
|
5
|
+
import { useStoreSubscription } from './useStoreSubscription.js'
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Options
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Options for useEntityList hook
|
|
13
|
+
*/
|
|
14
|
+
export interface UseEntityListOptions {
|
|
15
|
+
/** Optional filter criteria */
|
|
16
|
+
filter?: Record<string, unknown>
|
|
17
|
+
/** Optional ordering */
|
|
18
|
+
orderBy?: readonly Record<string, unknown>[]
|
|
19
|
+
/** Optional limit */
|
|
20
|
+
limit?: number
|
|
21
|
+
/** Optional offset */
|
|
22
|
+
offset?: number
|
|
23
|
+
/**
|
|
24
|
+
* Pre-resolved selection metadata.
|
|
25
|
+
* When provided, definer (3rd argument) is not needed.
|
|
26
|
+
* Used internally by EntityList, DataGrid, and DataView components.
|
|
27
|
+
*/
|
|
28
|
+
selection?: SelectionMeta
|
|
29
|
+
/** Optional query key for cache invalidation */
|
|
30
|
+
queryKey?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Result types
|
|
35
|
+
// ============================================================================
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Loading state for entity list accessor
|
|
39
|
+
*/
|
|
40
|
+
export interface LoadingEntityListAccessor {
|
|
41
|
+
readonly status: 'loading'
|
|
42
|
+
readonly isLoading: true
|
|
43
|
+
readonly isError: false
|
|
44
|
+
readonly isDirty: false
|
|
45
|
+
readonly items: never
|
|
46
|
+
readonly length: 0
|
|
47
|
+
add(data?: unknown): string
|
|
48
|
+
remove(key: string): void
|
|
49
|
+
move(fromIndex: number, toIndex: number): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Error state for entity list accessor
|
|
54
|
+
*/
|
|
55
|
+
export interface ErrorEntityListAccessor {
|
|
56
|
+
readonly status: 'error'
|
|
57
|
+
readonly isLoading: false
|
|
58
|
+
readonly isError: true
|
|
59
|
+
readonly error: FieldError
|
|
60
|
+
readonly isDirty: false
|
|
61
|
+
readonly items: never
|
|
62
|
+
readonly length: 0
|
|
63
|
+
add(data?: unknown): string
|
|
64
|
+
remove(key: string): void
|
|
65
|
+
move(fromIndex: number, toIndex: number): void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Ready state for entity list accessor
|
|
70
|
+
*/
|
|
71
|
+
export interface ReadyEntityListAccessor<T extends object> {
|
|
72
|
+
readonly status: 'ready'
|
|
73
|
+
readonly isLoading: false
|
|
74
|
+
readonly isError: false
|
|
75
|
+
readonly isDirty: boolean
|
|
76
|
+
readonly items: Array<EntityAccessor<T>>
|
|
77
|
+
readonly length: number
|
|
78
|
+
add(data?: Partial<T>): string
|
|
79
|
+
remove(key: string): void
|
|
80
|
+
move(fromIndex: number, toIndex: number): void
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Union of all entity list accessor states
|
|
85
|
+
*/
|
|
86
|
+
export type EntityListAccessorResult<T extends object> =
|
|
87
|
+
| LoadingEntityListAccessor
|
|
88
|
+
| ErrorEntityListAccessor
|
|
89
|
+
| ReadyEntityListAccessor<T>
|
|
90
|
+
|
|
91
|
+
// ============================================================================
|
|
92
|
+
// Internal helpers
|
|
93
|
+
// ============================================================================
|
|
94
|
+
|
|
95
|
+
function createLoadingListAccessor(): LoadingEntityListAccessor {
|
|
96
|
+
return {
|
|
97
|
+
status: 'loading',
|
|
98
|
+
isLoading: true,
|
|
99
|
+
isError: false,
|
|
100
|
+
isDirty: false,
|
|
101
|
+
get items(): never {
|
|
102
|
+
throw new Error('Cannot access items while loading')
|
|
103
|
+
},
|
|
104
|
+
length: 0,
|
|
105
|
+
add() {
|
|
106
|
+
throw new Error('Cannot add items while loading')
|
|
107
|
+
},
|
|
108
|
+
remove() {
|
|
109
|
+
throw new Error('Cannot remove items while loading')
|
|
110
|
+
},
|
|
111
|
+
move() {
|
|
112
|
+
throw new Error('Cannot move items while loading')
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function createErrorListAccessor(error: FieldError): ErrorEntityListAccessor {
|
|
118
|
+
return {
|
|
119
|
+
status: 'error',
|
|
120
|
+
isLoading: false,
|
|
121
|
+
isError: true,
|
|
122
|
+
error,
|
|
123
|
+
isDirty: false,
|
|
124
|
+
get items(): never {
|
|
125
|
+
throw new Error('Cannot access items after error')
|
|
126
|
+
},
|
|
127
|
+
length: 0,
|
|
128
|
+
add() {
|
|
129
|
+
throw new Error('Cannot add items after error')
|
|
130
|
+
},
|
|
131
|
+
remove() {
|
|
132
|
+
throw new Error('Cannot remove items after error')
|
|
133
|
+
},
|
|
134
|
+
move() {
|
|
135
|
+
throw new Error('Cannot move items after error')
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============================================================================
|
|
141
|
+
// Hook overloads
|
|
142
|
+
// ============================================================================
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Hook to fetch and manage a list of entities with role-expanded type inference.
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* ```tsx
|
|
149
|
+
* const authors = useEntityList(schema.Author, { roles: ['admin'] }, e => e.name().internalNotes())
|
|
150
|
+
* ```
|
|
151
|
+
*/
|
|
152
|
+
export function useEntityList<
|
|
153
|
+
TRoleMap extends Record<string, object>,
|
|
154
|
+
TRoles extends RoleNames<TRoleMap>,
|
|
155
|
+
TResult extends object,
|
|
156
|
+
>(
|
|
157
|
+
entity: EntityDef<TRoleMap>,
|
|
158
|
+
options: UseEntityListOptions & { roles: readonly TRoles[] },
|
|
159
|
+
definer: SelectionInput<EntityForRoles<TRoleMap, TRoles>, TResult>,
|
|
160
|
+
): EntityListAccessorResult<TResult>
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Hook to fetch and manage a list of entities with full type inference.
|
|
164
|
+
* Uses the common (narrowest) entity type when no roles are specified.
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```tsx
|
|
168
|
+
* const authors = useEntityList(schema.Author, {}, e => e.name().email())
|
|
169
|
+
* if (authors.status !== 'ready') return <Loading />
|
|
170
|
+
* return authors.items.map(a => <div key={a.id}>{a.name.value}</div>)
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
export function useEntityList<TRoleMap extends Record<string, object>, TResult extends object>(
|
|
174
|
+
entity: EntityDef<TRoleMap>,
|
|
175
|
+
options: UseEntityListOptions,
|
|
176
|
+
definer: SelectionInput<CommonEntity<TRoleMap>, TResult>,
|
|
177
|
+
): EntityListAccessorResult<TResult>
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Hook to fetch and manage a list of entities with pre-resolved selection.
|
|
181
|
+
*
|
|
182
|
+
* Used internally by EntityList, DataGrid, and DataView components
|
|
183
|
+
* that collect selection from JSX before calling this hook.
|
|
184
|
+
*/
|
|
185
|
+
export function useEntityList(
|
|
186
|
+
entity: EntityDef,
|
|
187
|
+
options: UseEntityListOptions & { selection: SelectionMeta },
|
|
188
|
+
): EntityListAccessorResult<object>
|
|
189
|
+
|
|
190
|
+
// ============================================================================
|
|
191
|
+
// Implementation
|
|
192
|
+
// ============================================================================
|
|
193
|
+
|
|
194
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
195
|
+
export function useEntityList(
|
|
196
|
+
entity: EntityDef<any>,
|
|
197
|
+
options: UseEntityListOptions,
|
|
198
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
199
|
+
definer?: SelectionInput<any, any>,
|
|
200
|
+
): EntityListAccessorResult<any> {
|
|
201
|
+
const schemaRegistry = useSchemaRegistry()
|
|
202
|
+
const entityType = entity.$name
|
|
203
|
+
const { store, dispatcher, batcher } = useBindxContext()
|
|
204
|
+
|
|
205
|
+
// --- Selection resolution ---
|
|
206
|
+
// resolveSelectionMeta is a pure function; useRef is called unconditionally.
|
|
207
|
+
const resolvedMeta = definer ? resolveSelectionMeta(definer) : null
|
|
208
|
+
const definerQueryKey = resolvedMeta ? JSON.stringify(buildQueryFromSelection(resolvedMeta)) : null
|
|
209
|
+
const selectionRef = useRef<{ meta: SelectionMeta; queryKey: string } | null>(null)
|
|
210
|
+
|
|
211
|
+
if (definerQueryKey && resolvedMeta) {
|
|
212
|
+
if (!selectionRef.current || selectionRef.current.queryKey !== definerQueryKey) {
|
|
213
|
+
selectionRef.current = { meta: resolvedMeta, queryKey: definerQueryKey }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const selectionMeta = definer ? selectionRef.current!.meta : options.selection!
|
|
218
|
+
|
|
219
|
+
// --- Stable options key ---
|
|
220
|
+
const optionsKey = useMemo(
|
|
221
|
+
() => JSON.stringify({
|
|
222
|
+
filter: options.filter ?? {},
|
|
223
|
+
orderBy: options.orderBy ?? [],
|
|
224
|
+
limit: options.limit,
|
|
225
|
+
offset: options.offset,
|
|
226
|
+
}),
|
|
227
|
+
[options.filter, options.orderBy, options.limit, options.offset],
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
// --- Effective query key for cache invalidation ---
|
|
231
|
+
const effectiveQueryKey = useMemo(() => {
|
|
232
|
+
if (options.queryKey) return options.queryKey
|
|
233
|
+
const query = buildQueryFromSelection(selectionMeta)
|
|
234
|
+
return JSON.stringify({ entityType, query })
|
|
235
|
+
}, [options.queryKey, selectionMeta, entityType])
|
|
236
|
+
|
|
237
|
+
// --- List state tracking ---
|
|
238
|
+
const listStateRef = useRef<{
|
|
239
|
+
status: 'loading' | 'error' | 'ready'
|
|
240
|
+
items: Array<{ id: string; data: object }>
|
|
241
|
+
error?: FieldError
|
|
242
|
+
}>({
|
|
243
|
+
status: 'loading',
|
|
244
|
+
items: [],
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const versionRef = useRef(0)
|
|
248
|
+
|
|
249
|
+
const listCacheRef = useRef<{
|
|
250
|
+
version: number
|
|
251
|
+
storeVersion: number
|
|
252
|
+
status: string
|
|
253
|
+
result: EntityListAccessorResult<object>
|
|
254
|
+
} | null>(null)
|
|
255
|
+
|
|
256
|
+
// --- Store subscription ---
|
|
257
|
+
const subscribe = useCallback(
|
|
258
|
+
(onStoreChange: () => void) => {
|
|
259
|
+
return store.subscribe(onStoreChange)
|
|
260
|
+
},
|
|
261
|
+
[store],
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
// --- Mutation methods ---
|
|
265
|
+
const addItem = useCallback(
|
|
266
|
+
(data?: Partial<object>): string => {
|
|
267
|
+
const tempId = store.createEntity(entityType, data as Record<string, unknown>)
|
|
268
|
+
const newItem = {
|
|
269
|
+
id: tempId,
|
|
270
|
+
data: { id: tempId, ...data } as object,
|
|
271
|
+
}
|
|
272
|
+
listStateRef.current.items = [...listStateRef.current.items, newItem]
|
|
273
|
+
versionRef.current++
|
|
274
|
+
store.notify()
|
|
275
|
+
return tempId
|
|
276
|
+
},
|
|
277
|
+
[entityType, store],
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
const removeItem = useCallback(
|
|
281
|
+
(key: string): void => {
|
|
282
|
+
const isNewEntity = isTempId(key) && !store.existsOnServer(entityType, key)
|
|
283
|
+
if (isNewEntity) {
|
|
284
|
+
store.removeEntity(entityType, key)
|
|
285
|
+
} else {
|
|
286
|
+
store.scheduleForDeletion(entityType, key)
|
|
287
|
+
}
|
|
288
|
+
listStateRef.current.items = listStateRef.current.items.filter(item => item.id !== key)
|
|
289
|
+
versionRef.current++
|
|
290
|
+
store.notify()
|
|
291
|
+
},
|
|
292
|
+
[entityType, store],
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
const moveItem = useCallback(
|
|
296
|
+
(fromIndex: number, toIndex: number): void => {
|
|
297
|
+
const items = [...listStateRef.current.items]
|
|
298
|
+
if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length) {
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
const [removed] = items.splice(fromIndex, 1)
|
|
302
|
+
if (removed) {
|
|
303
|
+
items.splice(toIndex, 0, removed)
|
|
304
|
+
listStateRef.current.items = items
|
|
305
|
+
versionRef.current++
|
|
306
|
+
store.notify()
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
[store],
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
// --- Snapshot ---
|
|
313
|
+
const getSnapshot = useCallback((): EntityListAccessorResult<object> => {
|
|
314
|
+
const state = listStateRef.current
|
|
315
|
+
const version = versionRef.current
|
|
316
|
+
const storeVersion = store.getVersion()
|
|
317
|
+
|
|
318
|
+
const cache = listCacheRef.current
|
|
319
|
+
if (cache && cache.version === version && cache.storeVersion === storeVersion && cache.status === state.status) {
|
|
320
|
+
return cache.result
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let result: EntityListAccessorResult<object>
|
|
324
|
+
|
|
325
|
+
if (state.status === 'loading') {
|
|
326
|
+
result = createLoadingListAccessor()
|
|
327
|
+
} else if (state.status === 'error') {
|
|
328
|
+
result = createErrorListAccessor(state.error!)
|
|
329
|
+
} else {
|
|
330
|
+
const items = state.items.map((item) => {
|
|
331
|
+
return EntityHandle.create<object>(
|
|
332
|
+
item.id,
|
|
333
|
+
entityType,
|
|
334
|
+
store,
|
|
335
|
+
dispatcher,
|
|
336
|
+
schemaRegistry as SchemaRegistry<Record<string, object>>,
|
|
337
|
+
) as unknown as EntityAccessor<object>
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
result = {
|
|
341
|
+
status: 'ready',
|
|
342
|
+
isLoading: false,
|
|
343
|
+
isError: false,
|
|
344
|
+
isDirty: false,
|
|
345
|
+
items,
|
|
346
|
+
length: items.length,
|
|
347
|
+
add: addItem,
|
|
348
|
+
remove: removeItem,
|
|
349
|
+
move: moveItem,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
listCacheRef.current = {
|
|
354
|
+
version,
|
|
355
|
+
storeVersion,
|
|
356
|
+
status: state.status,
|
|
357
|
+
result,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return result
|
|
361
|
+
}, [entityType, store, dispatcher, schemaRegistry, addItem, removeItem, moveItem])
|
|
362
|
+
|
|
363
|
+
const isEqual = useCallback(
|
|
364
|
+
(a: EntityListAccessorResult<object>, b: EntityListAccessorResult<object>): boolean => {
|
|
365
|
+
return a === b
|
|
366
|
+
},
|
|
367
|
+
[],
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
const accessor = useStoreSubscription({
|
|
371
|
+
subscribe,
|
|
372
|
+
getSnapshot,
|
|
373
|
+
isEqual,
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
// --- Data loading ---
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
const abortController = new AbortController()
|
|
379
|
+
|
|
380
|
+
listStateRef.current = { status: 'loading', items: [] }
|
|
381
|
+
versionRef.current++
|
|
382
|
+
|
|
383
|
+
const fetchData = async (): Promise<void> => {
|
|
384
|
+
try {
|
|
385
|
+
const spec = buildQueryFromSelection(selectionMeta)
|
|
386
|
+
const currentOptions = JSON.parse(optionsKey) as {
|
|
387
|
+
filter: Record<string, unknown>
|
|
388
|
+
orderBy: readonly Record<string, unknown>[]
|
|
389
|
+
limit?: number
|
|
390
|
+
offset?: number
|
|
391
|
+
}
|
|
392
|
+
const result = await batcher.enqueue(
|
|
393
|
+
{
|
|
394
|
+
type: 'list',
|
|
395
|
+
entityType,
|
|
396
|
+
filter: currentOptions.filter,
|
|
397
|
+
orderBy: currentOptions.orderBy,
|
|
398
|
+
limit: currentOptions.limit,
|
|
399
|
+
offset: currentOptions.offset,
|
|
400
|
+
spec,
|
|
401
|
+
},
|
|
402
|
+
{ signal: abortController.signal },
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
if (abortController.signal.aborted) return
|
|
406
|
+
|
|
407
|
+
if (result.type !== 'list') {
|
|
408
|
+
throw new Error('Unexpected query result type')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const items = result.data.map((data: Record<string, unknown>) => {
|
|
412
|
+
const id = data['id'] as string
|
|
413
|
+
dispatcher.dispatch(
|
|
414
|
+
setEntityData(entityType, id, data, true),
|
|
415
|
+
)
|
|
416
|
+
return { id, data: data as object }
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
listStateRef.current = { status: 'ready', items }
|
|
420
|
+
versionRef.current++
|
|
421
|
+
store.notify()
|
|
422
|
+
} catch (error) {
|
|
423
|
+
if (abortController.signal.aborted) return
|
|
424
|
+
|
|
425
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error))
|
|
426
|
+
listStateRef.current = {
|
|
427
|
+
status: 'error',
|
|
428
|
+
items: [],
|
|
429
|
+
error: createLoadError(normalizedError),
|
|
430
|
+
}
|
|
431
|
+
versionRef.current++
|
|
432
|
+
store.notify()
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
fetchData()
|
|
437
|
+
|
|
438
|
+
return () => {
|
|
439
|
+
abortController.abort()
|
|
440
|
+
}
|
|
441
|
+
}, [entityType, optionsKey, effectiveQueryKey, batcher, dispatcher, store, selectionMeta])
|
|
442
|
+
|
|
443
|
+
return accessor
|
|
444
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useSyncExternalStore, useCallback, useMemo } from 'react'
|
|
2
|
+
import type { FieldError } from '@contember/bindx'
|
|
3
|
+
import { useBindxContext } from './BackendAdapterContext.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Error state for an entity, including all field and relation errors.
|
|
7
|
+
*/
|
|
8
|
+
export interface EntityErrorsState {
|
|
9
|
+
/** Entity-level errors */
|
|
10
|
+
readonly entityErrors: readonly FieldError[]
|
|
11
|
+
/** Field errors keyed by field name */
|
|
12
|
+
readonly fieldErrors: ReadonlyMap<string, readonly FieldError[]>
|
|
13
|
+
/** Relation errors keyed by relation name */
|
|
14
|
+
readonly relationErrors: ReadonlyMap<string, readonly FieldError[]>
|
|
15
|
+
/** Whether the entity has any errors */
|
|
16
|
+
readonly hasErrors: boolean
|
|
17
|
+
/** Whether the entity has any client-side errors */
|
|
18
|
+
readonly hasClientErrors: boolean
|
|
19
|
+
/** Whether the entity has any server-side errors */
|
|
20
|
+
readonly hasServerErrors: boolean
|
|
21
|
+
/** All errors as a flat array */
|
|
22
|
+
readonly allErrors: readonly FieldError[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Hook for accessing error state for a specific entity.
|
|
27
|
+
* Provides reactive access to entity, field, and relation errors.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```tsx
|
|
31
|
+
* function ArticleEditor({ id }: { id: string }) {
|
|
32
|
+
* const { hasErrors, entityErrors, fieldErrors } = useEntityErrors('Article', id)
|
|
33
|
+
*
|
|
34
|
+
* return (
|
|
35
|
+
* <div>
|
|
36
|
+
* {hasErrors && (
|
|
37
|
+
* <div className="error-banner">
|
|
38
|
+
* There are validation errors. Please fix them before saving.
|
|
39
|
+
* </div>
|
|
40
|
+
* )}
|
|
41
|
+
* {entityErrors.map(e => <p key={e.message}>{e.message}</p>)}
|
|
42
|
+
* </div>
|
|
43
|
+
* )
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export function useEntityErrors(entityType: string, entityId: string): EntityErrorsState {
|
|
48
|
+
const { store } = useBindxContext()
|
|
49
|
+
|
|
50
|
+
// Create a stable function to collect all errors for this entity
|
|
51
|
+
const collectErrors = useCallback((): EntityErrorsState => {
|
|
52
|
+
const entityErrors = store.getEntityErrors(entityType, entityId)
|
|
53
|
+
|
|
54
|
+
// Collect field errors
|
|
55
|
+
const fieldErrorsMap = new Map<string, readonly FieldError[]>()
|
|
56
|
+
const keyPrefix = `${entityType}:${entityId}:`
|
|
57
|
+
|
|
58
|
+
// We need to iterate through all fields, but the store doesn't expose
|
|
59
|
+
// a method to list all field keys. Instead, we access the snapshot
|
|
60
|
+
// and check each field.
|
|
61
|
+
const snapshot = store.getEntitySnapshot(entityType, entityId)
|
|
62
|
+
if (snapshot?.data) {
|
|
63
|
+
for (const fieldName of Object.keys(snapshot.data as Record<string, unknown>)) {
|
|
64
|
+
const errors = store.getFieldErrors(entityType, entityId, fieldName)
|
|
65
|
+
if (errors.length > 0) {
|
|
66
|
+
fieldErrorsMap.set(fieldName, errors)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Collect relation errors - similar approach
|
|
72
|
+
const relationErrorsMap = new Map<string, readonly FieldError[]>()
|
|
73
|
+
if (snapshot?.data) {
|
|
74
|
+
for (const fieldName of Object.keys(snapshot.data as Record<string, unknown>)) {
|
|
75
|
+
const errors = store.getRelationErrors(entityType, entityId, fieldName)
|
|
76
|
+
if (errors.length > 0) {
|
|
77
|
+
relationErrorsMap.set(fieldName, errors)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Compute all errors flat array
|
|
83
|
+
const allErrors: FieldError[] = [...entityErrors]
|
|
84
|
+
for (const errors of fieldErrorsMap.values()) {
|
|
85
|
+
allErrors.push(...errors)
|
|
86
|
+
}
|
|
87
|
+
for (const errors of relationErrorsMap.values()) {
|
|
88
|
+
allErrors.push(...errors)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const hasErrors = allErrors.length > 0
|
|
92
|
+
const hasClientErrors = allErrors.some(e => e.source === 'client')
|
|
93
|
+
const hasServerErrors = allErrors.some(e => e.source === 'server')
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
entityErrors,
|
|
97
|
+
fieldErrors: fieldErrorsMap,
|
|
98
|
+
relationErrors: relationErrorsMap,
|
|
99
|
+
hasErrors,
|
|
100
|
+
hasClientErrors,
|
|
101
|
+
hasServerErrors,
|
|
102
|
+
allErrors,
|
|
103
|
+
}
|
|
104
|
+
}, [store, entityType, entityId])
|
|
105
|
+
|
|
106
|
+
// Subscribe to entity changes
|
|
107
|
+
const subscribe = useCallback(
|
|
108
|
+
(callback: () => void) => store.subscribeToEntity(entityType, entityId, callback),
|
|
109
|
+
[store, entityType, entityId],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
const errorsState = useSyncExternalStore(
|
|
113
|
+
subscribe,
|
|
114
|
+
collectErrors,
|
|
115
|
+
collectErrors,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return errorsState
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Hook for accessing persistence error state from the most recent persist operation.
|
|
123
|
+
* Useful for displaying a summary of what went wrong during persistence.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```tsx
|
|
127
|
+
* function SaveButton() {
|
|
128
|
+
* const { persistAll } = usePersist()
|
|
129
|
+
* const [lastResult, setLastResult] = useState<PersistenceResult | null>(null)
|
|
130
|
+
*
|
|
131
|
+
* const handleSave = async () => {
|
|
132
|
+
* const result = await persistAll({ rollbackOnError: true })
|
|
133
|
+
* setLastResult(result)
|
|
134
|
+
* }
|
|
135
|
+
*
|
|
136
|
+
* return (
|
|
137
|
+
* <div>
|
|
138
|
+
* <button onClick={handleSave}>Save</button>
|
|
139
|
+
* {lastResult && !lastResult.success && (
|
|
140
|
+
* <div className="error">
|
|
141
|
+
* Failed to save {lastResult.failedCount} items
|
|
142
|
+
* </div>
|
|
143
|
+
* )}
|
|
144
|
+
* </div>
|
|
145
|
+
* )
|
|
146
|
+
* }
|
|
147
|
+
* ```
|
|
148
|
+
*/
|
|
149
|
+
// Note: For tracking persistence results, use the result returned by persist methods
|
|
150
|
+
// in usePersist hook. This hook focuses on reactive entity error state.
|