@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,489 @@
|
|
|
1
|
+
import { useRef, useEffect, useMemo, useCallback } from 'react'
|
|
2
|
+
import type {
|
|
3
|
+
EntityDef,
|
|
4
|
+
EntityUniqueWhere,
|
|
5
|
+
SchemaRegistry,
|
|
6
|
+
SelectionInput,
|
|
7
|
+
SelectionMeta,
|
|
8
|
+
FieldError,
|
|
9
|
+
EntitySnapshot,
|
|
10
|
+
LoadStatus,
|
|
11
|
+
SelectedEntityFields,
|
|
12
|
+
CommonEntity,
|
|
13
|
+
EntityForRoles,
|
|
14
|
+
RoleNames,
|
|
15
|
+
} from '@contember/bindx'
|
|
16
|
+
import {
|
|
17
|
+
EntityHandle,
|
|
18
|
+
resolveSelectionMeta,
|
|
19
|
+
buildQueryFromSelection,
|
|
20
|
+
setEntityData,
|
|
21
|
+
setLoadState,
|
|
22
|
+
createLoadError,
|
|
23
|
+
} from '@contember/bindx'
|
|
24
|
+
import { useBindxContext, useSchemaRegistry } from './BackendAdapterContext.js'
|
|
25
|
+
import { useStoreSubscription } from './useStoreSubscription.js'
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Options
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Options for useEntity hook
|
|
33
|
+
*/
|
|
34
|
+
export interface UseEntityOptions {
|
|
35
|
+
/** Unique field(s) to identify the entity (e.g., { id: '...' } or { slug: '...' }) */
|
|
36
|
+
by: EntityUniqueWhere
|
|
37
|
+
/** If true, use cached data from store if available (default: false) */
|
|
38
|
+
cache?: boolean
|
|
39
|
+
/**
|
|
40
|
+
* Pre-resolved selection metadata.
|
|
41
|
+
* When provided, definer (3rd argument) is not needed.
|
|
42
|
+
* Used internally by Entity component.
|
|
43
|
+
*/
|
|
44
|
+
selection?: SelectionMeta
|
|
45
|
+
/** Optional query key for cache invalidation */
|
|
46
|
+
queryKey?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============================================================================
|
|
50
|
+
// Result types
|
|
51
|
+
// ============================================================================
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Loading state for entity accessor
|
|
55
|
+
*/
|
|
56
|
+
export interface LoadingEntityAccessor {
|
|
57
|
+
readonly status: 'loading'
|
|
58
|
+
readonly isLoading: true
|
|
59
|
+
readonly isError: false
|
|
60
|
+
readonly isNotFound: false
|
|
61
|
+
readonly isPersisting: false
|
|
62
|
+
readonly isDirty: false
|
|
63
|
+
readonly id: string
|
|
64
|
+
readonly fields: never
|
|
65
|
+
readonly data: never
|
|
66
|
+
persist(): Promise<void>
|
|
67
|
+
reset(): void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Error state for entity accessor
|
|
72
|
+
*/
|
|
73
|
+
export interface ErrorEntityAccessor {
|
|
74
|
+
readonly status: 'error'
|
|
75
|
+
readonly isLoading: false
|
|
76
|
+
readonly isError: true
|
|
77
|
+
readonly isNotFound: false
|
|
78
|
+
readonly error: FieldError
|
|
79
|
+
readonly isPersisting: false
|
|
80
|
+
readonly isDirty: false
|
|
81
|
+
readonly id: string
|
|
82
|
+
readonly fields: never
|
|
83
|
+
readonly data: never
|
|
84
|
+
persist(): Promise<void>
|
|
85
|
+
reset(): void
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Not found state for entity accessor
|
|
90
|
+
*/
|
|
91
|
+
export interface NotFoundEntityAccessor {
|
|
92
|
+
readonly status: 'not_found'
|
|
93
|
+
readonly isLoading: false
|
|
94
|
+
readonly isError: false
|
|
95
|
+
readonly isNotFound: true
|
|
96
|
+
readonly isPersisting: false
|
|
97
|
+
readonly isDirty: false
|
|
98
|
+
readonly id: string
|
|
99
|
+
readonly fields: never
|
|
100
|
+
readonly data: never
|
|
101
|
+
persist(): Promise<void>
|
|
102
|
+
reset(): void
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Ready state base interface for entity accessor
|
|
107
|
+
*/
|
|
108
|
+
export interface ReadyEntityAccessorBase<TEntity extends object, TSelected extends object = TEntity> {
|
|
109
|
+
readonly status: 'ready'
|
|
110
|
+
readonly isLoading: false
|
|
111
|
+
readonly isError: false
|
|
112
|
+
readonly isNotFound: false
|
|
113
|
+
readonly isPersisting: boolean
|
|
114
|
+
readonly isDirty: boolean
|
|
115
|
+
readonly id: string
|
|
116
|
+
readonly fields: SelectedEntityFields<TEntity, TSelected>
|
|
117
|
+
readonly data: TSelected
|
|
118
|
+
persist(): Promise<void>
|
|
119
|
+
reset(): void
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Ready state for entity accessor with direct field access via Proxy.
|
|
124
|
+
*/
|
|
125
|
+
export type ReadyEntityAccessor<TEntity extends object, TSelected extends object = TEntity> =
|
|
126
|
+
ReadyEntityAccessorBase<TEntity, TSelected> & SelectedEntityFields<TEntity, TSelected>
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Union of all entity accessor states
|
|
130
|
+
*/
|
|
131
|
+
export type EntityAccessorResult<TEntity extends object, TSelected extends object = TEntity> =
|
|
132
|
+
| LoadingEntityAccessor
|
|
133
|
+
| ErrorEntityAccessor
|
|
134
|
+
| NotFoundEntityAccessor
|
|
135
|
+
| ReadyEntityAccessor<TEntity, TSelected>
|
|
136
|
+
|
|
137
|
+
// ============================================================================
|
|
138
|
+
// Internal helpers
|
|
139
|
+
// ============================================================================
|
|
140
|
+
|
|
141
|
+
function createLoadingAccessor(id: string): LoadingEntityAccessor {
|
|
142
|
+
return {
|
|
143
|
+
status: 'loading',
|
|
144
|
+
isLoading: true,
|
|
145
|
+
isError: false,
|
|
146
|
+
isNotFound: false,
|
|
147
|
+
isPersisting: false,
|
|
148
|
+
isDirty: false,
|
|
149
|
+
id,
|
|
150
|
+
get fields(): never {
|
|
151
|
+
throw new Error('Cannot access fields while loading')
|
|
152
|
+
},
|
|
153
|
+
get data(): never {
|
|
154
|
+
throw new Error('Cannot access data while loading')
|
|
155
|
+
},
|
|
156
|
+
async persist() {
|
|
157
|
+
// No-op while loading
|
|
158
|
+
},
|
|
159
|
+
reset() {
|
|
160
|
+
// No-op while loading
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function createErrorAccessor(id: string, error: FieldError): ErrorEntityAccessor {
|
|
166
|
+
return {
|
|
167
|
+
status: 'error',
|
|
168
|
+
isLoading: false,
|
|
169
|
+
isError: true,
|
|
170
|
+
isNotFound: false,
|
|
171
|
+
error,
|
|
172
|
+
isPersisting: false,
|
|
173
|
+
isDirty: false,
|
|
174
|
+
id,
|
|
175
|
+
get fields(): never {
|
|
176
|
+
throw new Error('Cannot access fields after error')
|
|
177
|
+
},
|
|
178
|
+
get data(): never {
|
|
179
|
+
throw new Error('Cannot access data after error')
|
|
180
|
+
},
|
|
181
|
+
async persist() {
|
|
182
|
+
// No-op after error
|
|
183
|
+
},
|
|
184
|
+
reset() {
|
|
185
|
+
// No-op after error
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function createNotFoundAccessor(id: string): NotFoundEntityAccessor {
|
|
191
|
+
return {
|
|
192
|
+
status: 'not_found',
|
|
193
|
+
isLoading: false,
|
|
194
|
+
isError: false,
|
|
195
|
+
isNotFound: true,
|
|
196
|
+
isPersisting: false,
|
|
197
|
+
isDirty: false,
|
|
198
|
+
id,
|
|
199
|
+
get fields(): never {
|
|
200
|
+
throw new Error('Cannot access fields — entity not found')
|
|
201
|
+
},
|
|
202
|
+
get data(): never {
|
|
203
|
+
throw new Error('Cannot access data — entity not found')
|
|
204
|
+
},
|
|
205
|
+
async persist() {
|
|
206
|
+
// No-op for not found
|
|
207
|
+
},
|
|
208
|
+
reset() {
|
|
209
|
+
// No-op for not found
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// Hook overloads
|
|
216
|
+
// ============================================================================
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Hook to fetch and manage a single entity with role-expanded type inference.
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```tsx
|
|
223
|
+
* const article = useEntity(schema.Article, { by: { id }, roles: ['admin'] }, e => e.title().internalNotes())
|
|
224
|
+
* ```
|
|
225
|
+
*/
|
|
226
|
+
export function useEntity<
|
|
227
|
+
TRoleMap extends Record<string, object>,
|
|
228
|
+
TRoles extends RoleNames<TRoleMap>,
|
|
229
|
+
TResult extends object,
|
|
230
|
+
>(
|
|
231
|
+
entity: EntityDef<TRoleMap>,
|
|
232
|
+
options: UseEntityOptions & { roles: readonly TRoles[] },
|
|
233
|
+
definer: SelectionInput<EntityForRoles<TRoleMap, TRoles>, TResult>,
|
|
234
|
+
): EntityAccessorResult<EntityForRoles<TRoleMap, TRoles>, TResult>
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Hook to fetch and manage a single entity with full type inference.
|
|
238
|
+
* Uses the common (narrowest) entity type when no roles are specified.
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* ```tsx
|
|
242
|
+
* const article = useEntity(schema.Article, { by: { id } }, e => e.title().content())
|
|
243
|
+
* if (article.status !== 'ready') return <Loading />
|
|
244
|
+
* return <input value={article.title.value} onChange={...} />
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
export function useEntity<TRoleMap extends Record<string, object>, TResult extends object>(
|
|
248
|
+
entity: EntityDef<TRoleMap>,
|
|
249
|
+
options: UseEntityOptions,
|
|
250
|
+
definer: SelectionInput<CommonEntity<TRoleMap>, TResult>,
|
|
251
|
+
): EntityAccessorResult<CommonEntity<TRoleMap>, TResult>
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Hook to fetch and manage a single entity with pre-resolved selection.
|
|
255
|
+
*
|
|
256
|
+
* Used internally by Entity component that collects selection from JSX.
|
|
257
|
+
*/
|
|
258
|
+
export function useEntity(
|
|
259
|
+
entity: EntityDef,
|
|
260
|
+
options: UseEntityOptions & { selection: SelectionMeta },
|
|
261
|
+
): EntityAccessorResult<object, object>
|
|
262
|
+
|
|
263
|
+
// ============================================================================
|
|
264
|
+
// Implementation
|
|
265
|
+
// ============================================================================
|
|
266
|
+
|
|
267
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
268
|
+
export function useEntity(
|
|
269
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
270
|
+
entity: EntityDef<any>,
|
|
271
|
+
options: UseEntityOptions,
|
|
272
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
273
|
+
definer?: SelectionInput<any, any>,
|
|
274
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
275
|
+
): EntityAccessorResult<any, any> {
|
|
276
|
+
const schemaRegistry = useSchemaRegistry()
|
|
277
|
+
const entityType = entity.$name
|
|
278
|
+
const { store, dispatcher, batcher, batchPersister } = useBindxContext()
|
|
279
|
+
|
|
280
|
+
// --- Selection resolution ---
|
|
281
|
+
const resolvedMeta = definer ? resolveSelectionMeta(definer) : null
|
|
282
|
+
const definerQueryKey = resolvedMeta ? JSON.stringify(buildQueryFromSelection(resolvedMeta)) : null
|
|
283
|
+
const selectionRef = useRef<{ meta: SelectionMeta; queryKey: string } | null>(null)
|
|
284
|
+
|
|
285
|
+
if (definerQueryKey && resolvedMeta) {
|
|
286
|
+
if (!selectionRef.current || selectionRef.current.queryKey !== definerQueryKey) {
|
|
287
|
+
selectionRef.current = { meta: resolvedMeta, queryKey: definerQueryKey }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const selectionMeta = definer ? selectionRef.current!.meta : options.selection!
|
|
292
|
+
|
|
293
|
+
// --- Derive ID from 'by' ---
|
|
294
|
+
const by = options.by
|
|
295
|
+
const id = useMemo(() => {
|
|
296
|
+
if ('id' in by && typeof by['id'] === 'string') {
|
|
297
|
+
return by['id']
|
|
298
|
+
}
|
|
299
|
+
const firstValue = Object.values(by)[0]
|
|
300
|
+
return typeof firstValue === 'string' ? firstValue : String(firstValue)
|
|
301
|
+
}, [by])
|
|
302
|
+
|
|
303
|
+
const byKey = useMemo(() => JSON.stringify(by), [by])
|
|
304
|
+
const byRef = useRef(by)
|
|
305
|
+
byRef.current = by
|
|
306
|
+
|
|
307
|
+
// --- Effective query key ---
|
|
308
|
+
const effectiveQueryKey = useMemo(() => {
|
|
309
|
+
if (options.queryKey) return options.queryKey
|
|
310
|
+
const query = buildQueryFromSelection(selectionMeta)
|
|
311
|
+
return JSON.stringify(query)
|
|
312
|
+
}, [options.queryKey, selectionMeta])
|
|
313
|
+
|
|
314
|
+
// --- Store subscription ---
|
|
315
|
+
const subscribe = useCallback(
|
|
316
|
+
(callback: () => void) => {
|
|
317
|
+
return store.subscribeToEntity(entityType, id, callback)
|
|
318
|
+
},
|
|
319
|
+
[store, entityType, id],
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
interface SubscriptionSnapshot {
|
|
323
|
+
snapshot: EntitySnapshot | undefined
|
|
324
|
+
loadState: { status: LoadStatus; error?: FieldError } | undefined
|
|
325
|
+
isPersisting: boolean
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const getSnapshot = useCallback((): SubscriptionSnapshot => {
|
|
329
|
+
return {
|
|
330
|
+
snapshot: store.getEntitySnapshot(entityType, id),
|
|
331
|
+
loadState: store.getLoadState(entityType, id),
|
|
332
|
+
isPersisting: store.isPersisting(entityType, id),
|
|
333
|
+
}
|
|
334
|
+
}, [store, entityType, id])
|
|
335
|
+
|
|
336
|
+
const isEqual = useCallback((a: SubscriptionSnapshot, b: SubscriptionSnapshot): boolean => {
|
|
337
|
+
return (
|
|
338
|
+
a.snapshot === b.snapshot &&
|
|
339
|
+
a.loadState?.status === b.loadState?.status &&
|
|
340
|
+
a.isPersisting === b.isPersisting
|
|
341
|
+
)
|
|
342
|
+
}, [])
|
|
343
|
+
|
|
344
|
+
const { snapshot, loadState, isPersisting } = useStoreSubscription({
|
|
345
|
+
subscribe,
|
|
346
|
+
getSnapshot,
|
|
347
|
+
isEqual,
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
// --- Data loading ---
|
|
351
|
+
const fetchingRef = useRef<string | null>(null)
|
|
352
|
+
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
// Check cache first
|
|
355
|
+
if (options.cache && store.hasEntity(entityType, id)) {
|
|
356
|
+
dispatcher.dispatch(setLoadState(entityType, id, 'success'))
|
|
357
|
+
return
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Skip if already fetching same data
|
|
361
|
+
const fetchKey = `${entityType}:${byKey}:${effectiveQueryKey}`
|
|
362
|
+
if (fetchingRef.current === fetchKey) {
|
|
363
|
+
return
|
|
364
|
+
}
|
|
365
|
+
fetchingRef.current = fetchKey
|
|
366
|
+
|
|
367
|
+
const abortController = new AbortController()
|
|
368
|
+
|
|
369
|
+
dispatcher.dispatch(setLoadState(entityType, id, 'loading'))
|
|
370
|
+
|
|
371
|
+
const fetchData = async (): Promise<void> => {
|
|
372
|
+
try {
|
|
373
|
+
const spec = buildQueryFromSelection(selectionMeta)
|
|
374
|
+
const currentBy = byRef.current
|
|
375
|
+
const result = await batcher.enqueue(
|
|
376
|
+
{ type: 'get', entityType, by: currentBy, spec },
|
|
377
|
+
{ signal: abortController.signal },
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if (abortController.signal.aborted) return
|
|
381
|
+
|
|
382
|
+
if (result.type === 'get' && result.data === null) {
|
|
383
|
+
dispatcher.dispatch(setLoadState(entityType, id, 'not_found'))
|
|
384
|
+
} else if (result.type === 'get' && result.data) {
|
|
385
|
+
dispatcher.dispatch(
|
|
386
|
+
setEntityData(entityType, id, result.data, true),
|
|
387
|
+
)
|
|
388
|
+
dispatcher.dispatch(setLoadState(entityType, id, 'success'))
|
|
389
|
+
}
|
|
390
|
+
} catch (error) {
|
|
391
|
+
if (abortController.signal.aborted) return
|
|
392
|
+
if (error instanceof Error && error.name === 'AbortError') return
|
|
393
|
+
|
|
394
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error))
|
|
395
|
+
dispatcher.dispatch(
|
|
396
|
+
setLoadState(entityType, id, 'error', createLoadError(normalizedError)),
|
|
397
|
+
)
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
fetchData()
|
|
402
|
+
|
|
403
|
+
return () => {
|
|
404
|
+
abortController.abort()
|
|
405
|
+
if (fetchingRef.current === fetchKey) {
|
|
406
|
+
fetchingRef.current = null
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}, [entityType, id, byKey, effectiveQueryKey, options.cache, batcher, store, dispatcher, selectionMeta])
|
|
410
|
+
|
|
411
|
+
// --- EntityHandle ---
|
|
412
|
+
// Include snapshot in deps so handle reference changes when entity data changes.
|
|
413
|
+
// This ensures memo-wrapped children that receive individual field handles re-render.
|
|
414
|
+
const handle = useMemo(
|
|
415
|
+
() => EntityHandle.create(id, entityType, store, dispatcher, schemaRegistry as SchemaRegistry<Record<string, object>>),
|
|
416
|
+
[id, entityType, store, dispatcher, schemaRegistry, snapshot],
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
return () => {
|
|
421
|
+
handle.dispose()
|
|
422
|
+
}
|
|
423
|
+
}, [handle])
|
|
424
|
+
|
|
425
|
+
// --- Build accessor ---
|
|
426
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
427
|
+
const accessor = useMemo((): EntityAccessorResult<any, any> => {
|
|
428
|
+
if (!loadState || loadState.status === 'loading') {
|
|
429
|
+
return createLoadingAccessor(id)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (loadState.status === 'error') {
|
|
433
|
+
return createErrorAccessor(id, loadState.error!)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (loadState.status === 'not_found') {
|
|
437
|
+
return createNotFoundAccessor(id)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (!snapshot) {
|
|
441
|
+
return createLoadingAccessor(id)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Ready state
|
|
445
|
+
const realId = (snapshot.data as Record<string, unknown>)?.['id'] as string | undefined ?? id
|
|
446
|
+
|
|
447
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
448
|
+
const baseAccessor: ReadyEntityAccessorBase<any, any> = {
|
|
449
|
+
status: 'ready',
|
|
450
|
+
isLoading: false,
|
|
451
|
+
isError: false,
|
|
452
|
+
isNotFound: false,
|
|
453
|
+
isPersisting,
|
|
454
|
+
get isDirty() {
|
|
455
|
+
return handle.$isDirty
|
|
456
|
+
},
|
|
457
|
+
id: realId,
|
|
458
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
459
|
+
fields: handle.$fields as any,
|
|
460
|
+
data: snapshot.data,
|
|
461
|
+
async persist() {
|
|
462
|
+
await batchPersister.persist(entityType, realId)
|
|
463
|
+
},
|
|
464
|
+
reset() {
|
|
465
|
+
handle.reset()
|
|
466
|
+
},
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Wrap in Proxy for direct field access
|
|
470
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
471
|
+
return new Proxy(baseAccessor as any, {
|
|
472
|
+
get(target: Record<string, unknown>, prop: string | symbol, receiver: unknown) {
|
|
473
|
+
if (prop in target) {
|
|
474
|
+
return Reflect.get(target, prop, receiver)
|
|
475
|
+
}
|
|
476
|
+
if (typeof prop === 'string') {
|
|
477
|
+
const fields = target['fields'] as Record<string, unknown>
|
|
478
|
+
const fieldValue = fields[prop]
|
|
479
|
+
if (fieldValue !== undefined) {
|
|
480
|
+
return fieldValue
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return undefined
|
|
484
|
+
},
|
|
485
|
+
})
|
|
486
|
+
}, [snapshot, loadState, isPersisting, id, handle, batchPersister, entityType])
|
|
487
|
+
|
|
488
|
+
return accessor
|
|
489
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useInterceptEntity } from './useBindxEvents.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Convenience hook to run a callback before an entity is persisted.
|
|
5
|
+
*
|
|
6
|
+
* This is useful for validation, cleanup, or other pre-persist logic.
|
|
7
|
+
* The callback is called before the entity is sent to the server.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* function ArticleForm({ article }: { article: EntityRef<Article> }) {
|
|
12
|
+
* useEntityBeforePersist('Article', article.id, () => {
|
|
13
|
+
* // Validate or perform cleanup before persist
|
|
14
|
+
* if (!article.$fields.title.value) {
|
|
15
|
+
* article.$fields.title.addError('Title is required')
|
|
16
|
+
* }
|
|
17
|
+
* })
|
|
18
|
+
*
|
|
19
|
+
* return <form>...</form>
|
|
20
|
+
* }
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export function useEntityBeforePersist(
|
|
24
|
+
entityType: string,
|
|
25
|
+
entityId: string,
|
|
26
|
+
callback: () => void,
|
|
27
|
+
): void {
|
|
28
|
+
useInterceptEntity('entity:persisting', entityType, entityId, () => {
|
|
29
|
+
callback()
|
|
30
|
+
return { action: 'continue' }
|
|
31
|
+
})
|
|
32
|
+
}
|