@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.
Files changed (194) hide show
  1. package/dist/batching/QueryBatcher.d.ts +31 -0
  2. package/dist/batching/QueryBatcher.d.ts.map +1 -0
  3. package/dist/batching/index.d.ts +2 -0
  4. package/dist/batching/index.d.ts.map +1 -0
  5. package/dist/components/EntityScope.d.ts +28 -0
  6. package/dist/components/EntityScope.d.ts.map +1 -0
  7. package/dist/dataview/DataGrid.d.ts +45 -0
  8. package/dist/dataview/DataGrid.d.ts.map +1 -0
  9. package/dist/dataview/DataGridPagination.d.ts +24 -0
  10. package/dist/dataview/DataGridPagination.d.ts.map +1 -0
  11. package/dist/dataview/DataGridToolbar.d.ts +16 -0
  12. package/dist/dataview/DataGridToolbar.d.ts.map +1 -0
  13. package/dist/dataview/DataViewContext.d.ts +32 -0
  14. package/dist/dataview/DataViewContext.d.ts.map +1 -0
  15. package/dist/dataview/columns.d.ts +115 -0
  16. package/dist/dataview/columns.d.ts.map +1 -0
  17. package/dist/dataview/contextHooks.d.ts +48 -0
  18. package/dist/dataview/contextHooks.d.ts.map +1 -0
  19. package/dist/dataview/dataAttribute.d.ts +6 -0
  20. package/dist/dataview/dataAttribute.d.ts.map +1 -0
  21. package/dist/dataview/export.d.ts +46 -0
  22. package/dist/dataview/export.d.ts.map +1 -0
  23. package/dist/dataview/filterComponents.d.ts +127 -0
  24. package/dist/dataview/filterComponents.d.ts.map +1 -0
  25. package/dist/dataview/filterContext.d.ts +10 -0
  26. package/dist/dataview/filterContext.d.ts.map +1 -0
  27. package/dist/dataview/filterHooks.d.ts +74 -0
  28. package/dist/dataview/filterHooks.d.ts.map +1 -0
  29. package/dist/dataview/filterScope.d.ts +54 -0
  30. package/dist/dataview/filterScope.d.ts.map +1 -0
  31. package/dist/dataview/filterWrappers.d.ts +65 -0
  32. package/dist/dataview/filterWrappers.d.ts.map +1 -0
  33. package/dist/dataview/index.d.ts +21 -0
  34. package/dist/dataview/index.d.ts.map +1 -0
  35. package/dist/dataview/infiniteLoad.d.ts +32 -0
  36. package/dist/dataview/infiniteLoad.d.ts.map +1 -0
  37. package/dist/dataview/loaderComponents.d.ts +80 -0
  38. package/dist/dataview/loaderComponents.d.ts.map +1 -0
  39. package/dist/dataview/pagingComponents.d.ts +50 -0
  40. package/dist/dataview/pagingComponents.d.ts.map +1 -0
  41. package/dist/dataview/renderCellValue.d.ts +9 -0
  42. package/dist/dataview/renderCellValue.d.ts.map +1 -0
  43. package/dist/dataview/selectionComponents.d.ts +62 -0
  44. package/dist/dataview/selectionComponents.d.ts.map +1 -0
  45. package/dist/dataview/sortingComponents.d.ts +39 -0
  46. package/dist/dataview/sortingComponents.d.ts.map +1 -0
  47. package/dist/dataview/stateStorage.d.ts +19 -0
  48. package/dist/dataview/stateStorage.d.ts.map +1 -0
  49. package/dist/dataview/useDataView.d.ts +58 -0
  50. package/dist/dataview/useDataView.d.ts.map +1 -0
  51. package/dist/dataview/useDataViewState.d.ts +89 -0
  52. package/dist/dataview/useDataViewState.d.ts.map +1 -0
  53. package/dist/hooks/BackendAdapterContext.d.ts +110 -0
  54. package/dist/hooks/BackendAdapterContext.d.ts.map +1 -0
  55. package/dist/hooks/ContemberBindxProvider.d.ts +67 -0
  56. package/dist/hooks/ContemberBindxProvider.d.ts.map +1 -0
  57. package/dist/hooks/createBindx.d.ts +39 -0
  58. package/dist/hooks/createBindx.d.ts.map +1 -0
  59. package/dist/hooks/index.d.ts +10 -0
  60. package/dist/hooks/index.d.ts.map +1 -0
  61. package/dist/hooks/useBindxEvents.d.ts +83 -0
  62. package/dist/hooks/useBindxEvents.d.ts.map +1 -0
  63. package/dist/hooks/useEntity.d.ts +123 -0
  64. package/dist/hooks/useEntity.d.ts.map +1 -0
  65. package/dist/hooks/useEntityBeforePersist.d.ts +22 -0
  66. package/dist/hooks/useEntityBeforePersist.d.ts.map +1 -0
  67. package/dist/hooks/useEntityCore.d.ts +52 -0
  68. package/dist/hooks/useEntityCore.d.ts.map +1 -0
  69. package/dist/hooks/useEntityImpl.d.ts +105 -0
  70. package/dist/hooks/useEntityImpl.d.ts.map +1 -0
  71. package/dist/hooks/useEntityList.d.ts +102 -0
  72. package/dist/hooks/useEntityList.d.ts.map +1 -0
  73. package/dist/hooks/useEntityListCore.d.ts +49 -0
  74. package/dist/hooks/useEntityListCore.d.ts.map +1 -0
  75. package/dist/hooks/useEntityListImpl.d.ts +70 -0
  76. package/dist/hooks/useEntityListImpl.d.ts.map +1 -0
  77. package/dist/hooks/useErrors.d.ts +72 -0
  78. package/dist/hooks/useErrors.d.ts.map +1 -0
  79. package/dist/hooks/usePersist.d.ts +115 -0
  80. package/dist/hooks/usePersist.d.ts.map +1 -0
  81. package/dist/hooks/useSelectionCollection.d.ts +41 -0
  82. package/dist/hooks/useSelectionCollection.d.ts.map +1 -0
  83. package/dist/hooks/useSelectionCollectionForList.d.ts +40 -0
  84. package/dist/hooks/useSelectionCollectionForList.d.ts.map +1 -0
  85. package/dist/hooks/useStableSelectionMeta.d.ts +11 -0
  86. package/dist/hooks/useStableSelectionMeta.d.ts.map +1 -0
  87. package/dist/hooks/useStoreSubscription.d.ts +19 -0
  88. package/dist/hooks/useStoreSubscription.d.ts.map +1 -0
  89. package/dist/hooks/useUndo.d.ts +45 -0
  90. package/dist/hooks/useUndo.d.ts.map +1 -0
  91. package/dist/index.d.ts +17 -0
  92. package/dist/index.d.ts.map +1 -0
  93. package/dist/jsx/SelectionMeta.d.ts +2 -0
  94. package/dist/jsx/SelectionMeta.d.ts.map +1 -0
  95. package/dist/jsx/analyzer.d.ts +22 -0
  96. package/dist/jsx/analyzer.d.ts.map +1 -0
  97. package/dist/jsx/collectorProxy.d.ts +14 -0
  98. package/dist/jsx/collectorProxy.d.ts.map +1 -0
  99. package/dist/jsx/componentBuilder.d.ts +51 -0
  100. package/dist/jsx/componentBuilder.d.ts.map +1 -0
  101. package/dist/jsx/componentBuilder.types.d.ts +268 -0
  102. package/dist/jsx/componentBuilder.types.d.ts.map +1 -0
  103. package/dist/jsx/componentBuilderCompat.d.ts +17 -0
  104. package/dist/jsx/componentBuilderCompat.d.ts.map +1 -0
  105. package/dist/jsx/componentFactory.d.ts +39 -0
  106. package/dist/jsx/componentFactory.d.ts.map +1 -0
  107. package/dist/jsx/components/Entity.d.ts +82 -0
  108. package/dist/jsx/components/Entity.d.ts.map +1 -0
  109. package/dist/jsx/components/EntityList.d.ts +48 -0
  110. package/dist/jsx/components/EntityList.d.ts.map +1 -0
  111. package/dist/jsx/components/Field.d.ts +27 -0
  112. package/dist/jsx/components/Field.d.ts.map +1 -0
  113. package/dist/jsx/components/HasMany.d.ts +34 -0
  114. package/dist/jsx/components/HasMany.d.ts.map +1 -0
  115. package/dist/jsx/components/HasOne.d.ts +25 -0
  116. package/dist/jsx/components/HasOne.d.ts.map +1 -0
  117. package/dist/jsx/components/HasRole.d.ts +51 -0
  118. package/dist/jsx/components/HasRole.d.ts.map +1 -0
  119. package/dist/jsx/components/If.d.ts +51 -0
  120. package/dist/jsx/components/If.d.ts.map +1 -0
  121. package/dist/jsx/components/Show.d.ts +33 -0
  122. package/dist/jsx/components/Show.d.ts.map +1 -0
  123. package/dist/jsx/components/index.d.ts +8 -0
  124. package/dist/jsx/components/index.d.ts.map +1 -0
  125. package/dist/jsx/conditions.d.ts +310 -0
  126. package/dist/jsx/conditions.d.ts.map +1 -0
  127. package/dist/jsx/createComponent.d.ts +44 -0
  128. package/dist/jsx/createComponent.d.ts.map +1 -0
  129. package/dist/jsx/index.d.ts +19 -0
  130. package/dist/jsx/index.d.ts.map +1 -0
  131. package/dist/jsx/inlineProxy.d.ts +14 -0
  132. package/dist/jsx/inlineProxy.d.ts.map +1 -0
  133. package/dist/jsx/legacyTypes.d.ts +26 -0
  134. package/dist/jsx/legacyTypes.d.ts.map +1 -0
  135. package/dist/jsx/proxy.d.ts +2 -0
  136. package/dist/jsx/proxy.d.ts.map +1 -0
  137. package/dist/jsx/proxyShared.d.ts +14 -0
  138. package/dist/jsx/proxyShared.d.ts.map +1 -0
  139. package/dist/jsx/runtimeProxy.d.ts +11 -0
  140. package/dist/jsx/runtimeProxy.d.ts.map +1 -0
  141. package/dist/jsx/standaloneCreateComponent.d.ts +30 -0
  142. package/dist/jsx/standaloneCreateComponent.d.ts.map +1 -0
  143. package/dist/jsx/types.d.ts +109 -0
  144. package/dist/jsx/types.d.ts.map +1 -0
  145. package/dist/jsx/withCollector.d.ts +39 -0
  146. package/dist/jsx/withCollector.d.ts.map +1 -0
  147. package/dist/roles/RoleContext.d.ts +31 -0
  148. package/dist/roles/RoleContext.d.ts.map +1 -0
  149. package/dist/roles/index.d.ts +2 -0
  150. package/dist/roles/index.d.ts.map +1 -0
  151. package/package.json +34 -0
  152. package/src/batching/QueryBatcher.ts +114 -0
  153. package/src/batching/index.ts +1 -0
  154. package/src/components/EntityScope.tsx +43 -0
  155. package/src/hooks/BackendAdapterContext.tsx +214 -0
  156. package/src/hooks/ContemberBindxProvider.tsx +158 -0
  157. package/src/hooks/index.ts +61 -0
  158. package/src/hooks/useBindxEvents.ts +229 -0
  159. package/src/hooks/useEntity.ts +489 -0
  160. package/src/hooks/useEntityBeforePersist.ts +32 -0
  161. package/src/hooks/useEntityList.ts +444 -0
  162. package/src/hooks/useErrors.ts +150 -0
  163. package/src/hooks/usePersist.ts +377 -0
  164. package/src/hooks/useSelectionCollection.ts +122 -0
  165. package/src/hooks/useStoreSubscription.ts +51 -0
  166. package/src/hooks/useUndo.ts +83 -0
  167. package/src/index.ts +357 -0
  168. package/src/jsx/SelectionMeta.ts +2 -0
  169. package/src/jsx/analyzer.ts +261 -0
  170. package/src/jsx/collectorProxy.ts +282 -0
  171. package/src/jsx/componentBuilder.ts +180 -0
  172. package/src/jsx/componentBuilder.types.ts +463 -0
  173. package/src/jsx/componentBuilderCompat.ts +39 -0
  174. package/src/jsx/componentFactory.ts +400 -0
  175. package/src/jsx/components/Entity.tsx +383 -0
  176. package/src/jsx/components/EntityList.tsx +147 -0
  177. package/src/jsx/components/Field.tsx +79 -0
  178. package/src/jsx/components/HasMany.tsx +109 -0
  179. package/src/jsx/components/HasOne.tsx +78 -0
  180. package/src/jsx/components/HasRole.tsx +95 -0
  181. package/src/jsx/components/If.tsx +127 -0
  182. package/src/jsx/components/Show.tsx +56 -0
  183. package/src/jsx/components/index.ts +20 -0
  184. package/src/jsx/conditions.ts +472 -0
  185. package/src/jsx/createComponent.ts +175 -0
  186. package/src/jsx/index.ts +135 -0
  187. package/src/jsx/legacyTypes.ts +30 -0
  188. package/src/jsx/proxy.ts +1 -0
  189. package/src/jsx/proxyShared.ts +42 -0
  190. package/src/jsx/standaloneCreateComponent.ts +47 -0
  191. package/src/jsx/types.ts +192 -0
  192. package/src/jsx/withCollector.ts +45 -0
  193. package/src/roles/RoleContext.tsx +57 -0
  194. 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.