@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,377 @@
|
|
|
1
|
+
import { useSyncExternalStore, useCallback, useMemo, useRef } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
FIELD_REF_META,
|
|
4
|
+
type BatchPersister,
|
|
5
|
+
type DirtyEntity,
|
|
6
|
+
type EntityPersistResult,
|
|
7
|
+
type PersistenceResult,
|
|
8
|
+
type PersistScope,
|
|
9
|
+
type BatchPersistOptions,
|
|
10
|
+
type FieldRef,
|
|
11
|
+
type HasOneRef,
|
|
12
|
+
type HasManyRef,
|
|
13
|
+
type FieldRefMeta,
|
|
14
|
+
} from '@contember/bindx'
|
|
15
|
+
import { useBindxContext } from './BackendAdapterContext.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Any ref that has entity metadata (FieldRef, HasOneRef, HasManyRef)
|
|
19
|
+
*/
|
|
20
|
+
export type AnyRefWithMeta = { readonly [FIELD_REF_META]: FieldRefMeta }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extracts entity metadata from a ref
|
|
24
|
+
*/
|
|
25
|
+
function getRefMeta(ref: AnyRefWithMeta): FieldRefMeta {
|
|
26
|
+
return ref[FIELD_REF_META]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* API returned by usePersist hook
|
|
31
|
+
*/
|
|
32
|
+
export interface PersistApi {
|
|
33
|
+
/** Persist all dirty entities in a transaction */
|
|
34
|
+
persistAll(options?: BatchPersistOptions): Promise<PersistenceResult>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Persist a single entity.
|
|
38
|
+
* Can be called with entityType/entityId or with a ref (FieldRef, HasOneRef, HasManyRef).
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* // With entityType and entityId
|
|
43
|
+
* persist('Article', 'abc-123')
|
|
44
|
+
*
|
|
45
|
+
* // With a FieldRef - persists the entity the field belongs to
|
|
46
|
+
* persist(article.fields.title)
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
persist(ref: AnyRefWithMeta, options?: BatchPersistOptions): Promise<EntityPersistResult>
|
|
50
|
+
persist(entityType: string, entityId: string, options?: BatchPersistOptions): Promise<EntityPersistResult>
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Persist specific fields of an entity.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```tsx
|
|
57
|
+
* // With entityType, entityId, and field names
|
|
58
|
+
* persistFields('Article', 'abc-123', ['title', 'content'])
|
|
59
|
+
*
|
|
60
|
+
* // With a FieldRef - persists just that field
|
|
61
|
+
* persistFields(article.fields.title)
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
persistFields(ref: AnyRefWithMeta, options?: BatchPersistOptions): Promise<EntityPersistResult>
|
|
65
|
+
persistFields(
|
|
66
|
+
entityType: string,
|
|
67
|
+
entityId: string,
|
|
68
|
+
fields: readonly string[],
|
|
69
|
+
options?: BatchPersistOptions,
|
|
70
|
+
): Promise<EntityPersistResult>
|
|
71
|
+
|
|
72
|
+
/** Persist with custom scope */
|
|
73
|
+
persistScope(scope: PersistScope, options?: BatchPersistOptions): Promise<PersistenceResult>
|
|
74
|
+
|
|
75
|
+
/** Check if any entity is currently being persisted */
|
|
76
|
+
isPersisting: boolean
|
|
77
|
+
|
|
78
|
+
/** Get all dirty entities */
|
|
79
|
+
dirtyEntities: readonly DirtyEntity[]
|
|
80
|
+
|
|
81
|
+
/** Check if there are any dirty entities */
|
|
82
|
+
isDirty: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Hook for accessing the persistence API.
|
|
87
|
+
* Provides methods to persist all dirty entities, a single entity, or specific fields.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```tsx
|
|
91
|
+
* function SaveButton() {
|
|
92
|
+
* const { persistAll, isPersisting, isDirty } = usePersist()
|
|
93
|
+
*
|
|
94
|
+
* return (
|
|
95
|
+
* <button
|
|
96
|
+
* onClick={() => persistAll()}
|
|
97
|
+
* disabled={isPersisting || !isDirty}
|
|
98
|
+
* >
|
|
99
|
+
* {isPersisting ? 'Saving...' : 'Save All'}
|
|
100
|
+
* </button>
|
|
101
|
+
* )
|
|
102
|
+
* }
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export function usePersist(): PersistApi {
|
|
106
|
+
const { batchPersister, store } = useBindxContext()
|
|
107
|
+
|
|
108
|
+
if (!batchPersister) {
|
|
109
|
+
throw new Error('usePersist requires BatchPersister to be configured in the context')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const changeRegistry = batchPersister.getChangeRegistry()
|
|
113
|
+
|
|
114
|
+
// Cache for dirty entities snapshot to avoid infinite loops
|
|
115
|
+
const dirtyEntitiesCacheRef = useRef<readonly DirtyEntity[]>([])
|
|
116
|
+
|
|
117
|
+
// Subscribe to store changes for dirty tracking
|
|
118
|
+
const dirtyEntities = useSyncExternalStore(
|
|
119
|
+
useCallback((callback) => store.subscribe(callback), [store]),
|
|
120
|
+
useCallback(() => {
|
|
121
|
+
const newDirtyEntities = changeRegistry.getDirtyEntities()
|
|
122
|
+
// Compare by length and entity keys to avoid unnecessary updates
|
|
123
|
+
const cached = dirtyEntitiesCacheRef.current
|
|
124
|
+
if (
|
|
125
|
+
cached.length === newDirtyEntities.length &&
|
|
126
|
+
cached.every((e, i) => {
|
|
127
|
+
const newE = newDirtyEntities[i]
|
|
128
|
+
return newE && e.entityType === newE.entityType && e.entityId === newE.entityId
|
|
129
|
+
})
|
|
130
|
+
) {
|
|
131
|
+
return cached
|
|
132
|
+
}
|
|
133
|
+
dirtyEntitiesCacheRef.current = newDirtyEntities
|
|
134
|
+
return newDirtyEntities
|
|
135
|
+
}, [changeRegistry]),
|
|
136
|
+
useCallback(() => changeRegistry.getDirtyEntities(), [changeRegistry]),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
// Cache for isPersisting snapshot
|
|
140
|
+
const isPersistingCacheRef = useRef<boolean>(false)
|
|
141
|
+
|
|
142
|
+
// Subscribe to in-flight changes
|
|
143
|
+
const isPersisting = useSyncExternalStore(
|
|
144
|
+
useCallback((callback) => changeRegistry.subscribe(callback), [changeRegistry]),
|
|
145
|
+
useCallback(() => {
|
|
146
|
+
const newIsPersisting = changeRegistry.hasInFlight()
|
|
147
|
+
if (isPersistingCacheRef.current === newIsPersisting) {
|
|
148
|
+
return isPersistingCacheRef.current
|
|
149
|
+
}
|
|
150
|
+
isPersistingCacheRef.current = newIsPersisting
|
|
151
|
+
return newIsPersisting
|
|
152
|
+
}, [changeRegistry]),
|
|
153
|
+
useCallback(() => changeRegistry.hasInFlight(), [changeRegistry]),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const persistAll = useCallback(
|
|
157
|
+
(options?: BatchPersistOptions) => batchPersister.persistAll(options),
|
|
158
|
+
[batchPersister],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const persist = useCallback(
|
|
162
|
+
(
|
|
163
|
+
refOrEntityType: AnyRefWithMeta | string,
|
|
164
|
+
entityIdOrOptions?: string | BatchPersistOptions,
|
|
165
|
+
options?: BatchPersistOptions,
|
|
166
|
+
) => {
|
|
167
|
+
if (typeof refOrEntityType === 'string') {
|
|
168
|
+
// Called with (entityType, entityId, options?)
|
|
169
|
+
return batchPersister.persist(refOrEntityType, entityIdOrOptions as string, options)
|
|
170
|
+
}
|
|
171
|
+
// Called with (ref, options?)
|
|
172
|
+
const meta = getRefMeta(refOrEntityType)
|
|
173
|
+
return batchPersister.persist(meta.entityType, meta.entityId, entityIdOrOptions as BatchPersistOptions | undefined)
|
|
174
|
+
},
|
|
175
|
+
[batchPersister],
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
const persistFields = useCallback(
|
|
179
|
+
(
|
|
180
|
+
refOrEntityType: AnyRefWithMeta | string,
|
|
181
|
+
entityIdOrOptions?: string | BatchPersistOptions,
|
|
182
|
+
fieldsOrOptions?: readonly string[] | BatchPersistOptions,
|
|
183
|
+
options?: BatchPersistOptions,
|
|
184
|
+
) => {
|
|
185
|
+
if (typeof refOrEntityType === 'string') {
|
|
186
|
+
// Called with (entityType, entityId, fields, options?)
|
|
187
|
+
return batchPersister.persistFields(
|
|
188
|
+
refOrEntityType,
|
|
189
|
+
entityIdOrOptions as string,
|
|
190
|
+
fieldsOrOptions as readonly string[],
|
|
191
|
+
options,
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
// Called with (ref, options?) - persist just this field
|
|
195
|
+
const meta = getRefMeta(refOrEntityType)
|
|
196
|
+
return batchPersister.persistFields(
|
|
197
|
+
meta.entityType,
|
|
198
|
+
meta.entityId,
|
|
199
|
+
[meta.fieldName],
|
|
200
|
+
entityIdOrOptions as BatchPersistOptions | undefined,
|
|
201
|
+
)
|
|
202
|
+
},
|
|
203
|
+
[batchPersister],
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
const persistScope = useCallback(
|
|
207
|
+
(scope: PersistScope, options?: BatchPersistOptions) =>
|
|
208
|
+
batchPersister.persistScope(scope, options),
|
|
209
|
+
[batchPersister],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return useMemo(
|
|
213
|
+
() => ({
|
|
214
|
+
persistAll,
|
|
215
|
+
persist,
|
|
216
|
+
persistFields,
|
|
217
|
+
persistScope,
|
|
218
|
+
isPersisting,
|
|
219
|
+
dirtyEntities,
|
|
220
|
+
isDirty: dirtyEntities.length > 0,
|
|
221
|
+
}),
|
|
222
|
+
[persistAll, persist, persistFields, persistScope, isPersisting, dirtyEntities],
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* API returned by usePersistEntity hook
|
|
228
|
+
*/
|
|
229
|
+
export interface EntityPersistApi {
|
|
230
|
+
/** Persist this entity */
|
|
231
|
+
persist(options?: BatchPersistOptions): Promise<EntityPersistResult>
|
|
232
|
+
|
|
233
|
+
/** Persist specific fields of this entity */
|
|
234
|
+
persistFields(fields: readonly string[], options?: BatchPersistOptions): Promise<EntityPersistResult>
|
|
235
|
+
|
|
236
|
+
/** Check if this entity is currently being persisted */
|
|
237
|
+
isPersisting: boolean
|
|
238
|
+
|
|
239
|
+
/** Check if this entity has any dirty changes */
|
|
240
|
+
isDirty: boolean
|
|
241
|
+
|
|
242
|
+
/** Get dirty fields for this entity */
|
|
243
|
+
dirtyFields: readonly string[]
|
|
244
|
+
|
|
245
|
+
/** Get dirty relations for this entity */
|
|
246
|
+
dirtyRelations: readonly string[]
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Hook for accessing persistence API for a specific entity.
|
|
251
|
+
* Provides convenience methods scoped to a single entity.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```tsx
|
|
255
|
+
* function ArticleEditor({ id }: { id: string }) {
|
|
256
|
+
* const article = useEntity('Article', { id })
|
|
257
|
+
* const { persist, persistFields, isPersisting, isDirty } = usePersistEntity('Article', id)
|
|
258
|
+
*
|
|
259
|
+
* return (
|
|
260
|
+
* <div>
|
|
261
|
+
* <input {...article.fields.title.inputProps} />
|
|
262
|
+
* <button onClick={() => persistFields(['title'])} disabled={isPersisting}>
|
|
263
|
+
* Save Title
|
|
264
|
+
* </button>
|
|
265
|
+
* <button onClick={() => persist()} disabled={isPersisting || !isDirty}>
|
|
266
|
+
* Save All
|
|
267
|
+
* </button>
|
|
268
|
+
* </div>
|
|
269
|
+
* )
|
|
270
|
+
* }
|
|
271
|
+
* ```
|
|
272
|
+
*/
|
|
273
|
+
export function usePersistEntity(entityType: string, entityId: string): EntityPersistApi {
|
|
274
|
+
const { batchPersister, store } = useBindxContext()
|
|
275
|
+
|
|
276
|
+
if (!batchPersister) {
|
|
277
|
+
throw new Error('usePersistEntity requires BatchPersister to be configured in the context')
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const changeRegistry = batchPersister.getChangeRegistry()
|
|
281
|
+
|
|
282
|
+
// Caches for snapshot stability
|
|
283
|
+
const dirtyFieldsCacheRef = useRef<readonly string[]>([])
|
|
284
|
+
const dirtyRelationsCacheRef = useRef<readonly string[]>([])
|
|
285
|
+
const isPersistingCacheRef = useRef<boolean>(false)
|
|
286
|
+
|
|
287
|
+
const dirtyFields = useSyncExternalStore(
|
|
288
|
+
useCallback(
|
|
289
|
+
(callback) => store.subscribeToEntity(entityType, entityId, callback),
|
|
290
|
+
[store, entityType, entityId],
|
|
291
|
+
),
|
|
292
|
+
useCallback(() => {
|
|
293
|
+
const newDirtyFields = store.getDirtyFields(entityType, entityId)
|
|
294
|
+
const cached = dirtyFieldsCacheRef.current
|
|
295
|
+
if (
|
|
296
|
+
cached.length === newDirtyFields.length &&
|
|
297
|
+
cached.every((f, i) => f === newDirtyFields[i])
|
|
298
|
+
) {
|
|
299
|
+
return cached
|
|
300
|
+
}
|
|
301
|
+
dirtyFieldsCacheRef.current = newDirtyFields
|
|
302
|
+
return newDirtyFields
|
|
303
|
+
}, [store, entityType, entityId]),
|
|
304
|
+
useCallback(() => store.getDirtyFields(entityType, entityId), [store, entityType, entityId]),
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
const dirtyRelations = useSyncExternalStore(
|
|
308
|
+
useCallback(
|
|
309
|
+
(callback) => store.subscribeToEntity(entityType, entityId, callback),
|
|
310
|
+
[store, entityType, entityId],
|
|
311
|
+
),
|
|
312
|
+
useCallback(() => {
|
|
313
|
+
const newDirtyRelations = store.getDirtyRelations(entityType, entityId)
|
|
314
|
+
const cached = dirtyRelationsCacheRef.current
|
|
315
|
+
if (
|
|
316
|
+
cached.length === newDirtyRelations.length &&
|
|
317
|
+
cached.every((r, i) => r === newDirtyRelations[i])
|
|
318
|
+
) {
|
|
319
|
+
return cached
|
|
320
|
+
}
|
|
321
|
+
dirtyRelationsCacheRef.current = newDirtyRelations
|
|
322
|
+
return newDirtyRelations
|
|
323
|
+
}, [store, entityType, entityId]),
|
|
324
|
+
useCallback(() => store.getDirtyRelations(entityType, entityId), [store, entityType, entityId]),
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
const isPersisting = useSyncExternalStore(
|
|
328
|
+
useCallback(
|
|
329
|
+
(callback) => {
|
|
330
|
+
const unsubStore = store.subscribeToEntity(entityType, entityId, callback)
|
|
331
|
+
const unsubRegistry = changeRegistry.subscribe(callback)
|
|
332
|
+
return () => {
|
|
333
|
+
unsubStore()
|
|
334
|
+
unsubRegistry()
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
[store, changeRegistry, entityType, entityId],
|
|
338
|
+
),
|
|
339
|
+
useCallback(() => {
|
|
340
|
+
const newIsPersisting = changeRegistry.isInFlight(entityType, entityId)
|
|
341
|
+
if (isPersistingCacheRef.current === newIsPersisting) {
|
|
342
|
+
return isPersistingCacheRef.current
|
|
343
|
+
}
|
|
344
|
+
isPersistingCacheRef.current = newIsPersisting
|
|
345
|
+
return newIsPersisting
|
|
346
|
+
}, [changeRegistry, entityType, entityId]),
|
|
347
|
+
useCallback(
|
|
348
|
+
() => changeRegistry.isInFlight(entityType, entityId),
|
|
349
|
+
[changeRegistry, entityType, entityId],
|
|
350
|
+
),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
const persist = useCallback(
|
|
354
|
+
(options?: BatchPersistOptions) => batchPersister.persist(entityType, entityId, options),
|
|
355
|
+
[batchPersister, entityType, entityId],
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
const persistFields = useCallback(
|
|
359
|
+
(fields: readonly string[], options?: BatchPersistOptions) =>
|
|
360
|
+
batchPersister.persistFields(entityType, entityId, fields, options),
|
|
361
|
+
[batchPersister, entityType, entityId],
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
const isDirty = dirtyFields.length > 0 || dirtyRelations.length > 0
|
|
365
|
+
|
|
366
|
+
return useMemo(
|
|
367
|
+
() => ({
|
|
368
|
+
persist,
|
|
369
|
+
persistFields,
|
|
370
|
+
isPersisting,
|
|
371
|
+
isDirty,
|
|
372
|
+
dirtyFields,
|
|
373
|
+
dirtyRelations,
|
|
374
|
+
}),
|
|
375
|
+
[persist, persistFields, isPersisting, isDirty, dirtyFields, dirtyRelations],
|
|
376
|
+
)
|
|
377
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useContext, useMemo, useRef, type ReactNode } from 'react'
|
|
2
|
+
import { createCollectorProxy } from '../jsx/proxy.js'
|
|
3
|
+
import { collectSelection, debugSelection } from '../jsx/analyzer.js'
|
|
4
|
+
import { mergeSelections } from '../jsx/SelectionMeta.js'
|
|
5
|
+
import { buildQueryFromSelection, SelectionScope } from '@contember/bindx'
|
|
6
|
+
import type { SelectionMeta, SchemaRegistry } from '@contember/bindx'
|
|
7
|
+
import type { EntityAccessor } from '../jsx/types.js'
|
|
8
|
+
import { BindxContext } from './BackendAdapterContext.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options for useSelectionCollection hook.
|
|
12
|
+
*/
|
|
13
|
+
export interface UseSelectionCollectionOptions {
|
|
14
|
+
/** Entity type name (for debugging) */
|
|
15
|
+
entityType: string
|
|
16
|
+
/** Stable key for useMemo dependencies (e.g., entityId or JSON-serialized options) */
|
|
17
|
+
depsKey: string
|
|
18
|
+
/** Collector function — receives a collector proxy, returns JSX for analysis */
|
|
19
|
+
collect: (collector: EntityAccessor<unknown>) => ReactNode
|
|
20
|
+
/** Extra data to include in the query key (e.g., filter/orderBy for lists) */
|
|
21
|
+
queryKeyExtra?: Record<string, unknown>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Result from useSelectionCollection hook.
|
|
26
|
+
*/
|
|
27
|
+
export interface SelectionCollectionResult {
|
|
28
|
+
/** Selection metadata for query building */
|
|
29
|
+
selection: SelectionMeta
|
|
30
|
+
/** Stable query key for dependency tracking */
|
|
31
|
+
queryKey: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Cached selection data
|
|
36
|
+
*/
|
|
37
|
+
interface SelectionCache {
|
|
38
|
+
selection: SelectionMeta
|
|
39
|
+
queryKey: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Hook for collecting field selection from JSX children.
|
|
44
|
+
* Implements the two-phase rendering approach:
|
|
45
|
+
* 1. Creates a collector proxy
|
|
46
|
+
* 2. Calls the collect function with the proxy to gather field accesses
|
|
47
|
+
* 3. Analyzes returned JSX for component-level selections
|
|
48
|
+
* 4. Merges both sources of selection
|
|
49
|
+
*
|
|
50
|
+
* **Limitation:** Selection is collected once per `depsKey`.
|
|
51
|
+
* Fields accessed conditionally in the collect function are only captured
|
|
52
|
+
* if they are accessed during the initial collection phase.
|
|
53
|
+
*
|
|
54
|
+
* @internal This hook is for internal use only.
|
|
55
|
+
*/
|
|
56
|
+
export function useSelectionCollection(
|
|
57
|
+
options: UseSelectionCollectionOptions,
|
|
58
|
+
): SelectionCollectionResult {
|
|
59
|
+
const { entityType, depsKey, collect, queryKeyExtra } = options
|
|
60
|
+
const bindxContext = useContext(BindxContext)
|
|
61
|
+
|
|
62
|
+
// Stable collect ref - we use a ref to avoid re-running useMemo on every render
|
|
63
|
+
const collectRef = useRef(collect)
|
|
64
|
+
collectRef.current = collect
|
|
65
|
+
|
|
66
|
+
// Cache for selection to avoid unnecessary refetches
|
|
67
|
+
const selectionCacheRef = useRef<SelectionCache | null>(null)
|
|
68
|
+
|
|
69
|
+
const schemaRegistry = bindxContext?.schema ?? null
|
|
70
|
+
|
|
71
|
+
// Collection phase - runs on every render but caches based on content
|
|
72
|
+
const result = useMemo((): SelectionCollectionResult => {
|
|
73
|
+
// Create collector proxy using SelectionScope tree
|
|
74
|
+
const scope = new SelectionScope()
|
|
75
|
+
const collector = createCollectorProxy<unknown>(scope, entityType, schemaRegistry as SchemaRegistry<Record<string, object>> | null)
|
|
76
|
+
|
|
77
|
+
// Call collect function with the proxy to gather field access
|
|
78
|
+
const jsx = collectRef.current(collector)
|
|
79
|
+
|
|
80
|
+
// Analyze the returned JSX for component-level selections
|
|
81
|
+
let jsxSel: SelectionMeta
|
|
82
|
+
try {
|
|
83
|
+
jsxSel = collectSelection(jsx)
|
|
84
|
+
} catch {
|
|
85
|
+
// staticRender of nested components may crash during collection
|
|
86
|
+
jsxSel = { fields: new Map() }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Convert scope to SelectionMeta and merge with JSX selection
|
|
90
|
+
const selection = scope.toSelectionMeta()
|
|
91
|
+
mergeSelections(selection, jsxSel)
|
|
92
|
+
|
|
93
|
+
// Debug output can be enabled via the debug prop on BindxProvider
|
|
94
|
+
if (bindxContext?.debug) {
|
|
95
|
+
console.log('[Selection] Collected selection for', entityType, ':')
|
|
96
|
+
console.log(debugSelection(selection))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Create a stable key from the selection to detect actual changes
|
|
100
|
+
const query = buildQueryFromSelection(selection)
|
|
101
|
+
const newQueryKey = queryKeyExtra
|
|
102
|
+
? JSON.stringify({ query, ...queryKeyExtra })
|
|
103
|
+
: JSON.stringify(query)
|
|
104
|
+
|
|
105
|
+
// If the selection hasn't actually changed, return cached values
|
|
106
|
+
const cache = selectionCacheRef.current
|
|
107
|
+
if (cache && cache.queryKey === newQueryKey) {
|
|
108
|
+
return cache
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Update cache with new values
|
|
112
|
+
const newCache: SelectionCache = {
|
|
113
|
+
selection,
|
|
114
|
+
queryKey: newQueryKey,
|
|
115
|
+
}
|
|
116
|
+
selectionCacheRef.current = newCache
|
|
117
|
+
|
|
118
|
+
return newCache
|
|
119
|
+
}, [entityType, depsKey, schemaRegistry]) // Only depend on entity identity, deps key, and schema
|
|
120
|
+
|
|
121
|
+
return result
|
|
122
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useCallback, useRef, useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Options for useStoreSubscription hook.
|
|
5
|
+
*/
|
|
6
|
+
export interface UseStoreSubscriptionOptions<T> {
|
|
7
|
+
/** Function to subscribe to store changes */
|
|
8
|
+
subscribe: (callback: () => void) => () => void
|
|
9
|
+
/** Function to get current snapshot */
|
|
10
|
+
getSnapshot: () => T
|
|
11
|
+
/** Optional equality function for cache comparison (defaults to ===) */
|
|
12
|
+
isEqual?: (a: T, b: T) => boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generic hook for subscribing to store changes with useSyncExternalStore.
|
|
17
|
+
* Provides snapshot caching for referential stability.
|
|
18
|
+
*
|
|
19
|
+
* @internal This hook is for internal use only.
|
|
20
|
+
*/
|
|
21
|
+
export function useStoreSubscription<T>(
|
|
22
|
+
options: UseStoreSubscriptionOptions<T>,
|
|
23
|
+
): T {
|
|
24
|
+
const { subscribe, getSnapshot, isEqual = defaultIsEqual } = options
|
|
25
|
+
|
|
26
|
+
// Cache for snapshot to ensure referential stability
|
|
27
|
+
const cacheRef = useRef<{ value: T } | null>(null)
|
|
28
|
+
|
|
29
|
+
// Stable getSnapshot callback with caching
|
|
30
|
+
const getSnapshotCached = useCallback((): T => {
|
|
31
|
+
const newValue = getSnapshot()
|
|
32
|
+
|
|
33
|
+
// Return cached value if equal
|
|
34
|
+
if (cacheRef.current !== null && isEqual(cacheRef.current.value, newValue)) {
|
|
35
|
+
return cacheRef.current.value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Update cache and return new value
|
|
39
|
+
cacheRef.current = { value: newValue }
|
|
40
|
+
return newValue
|
|
41
|
+
}, [getSnapshot, isEqual])
|
|
42
|
+
|
|
43
|
+
return useSyncExternalStore(subscribe, getSnapshotCached, getSnapshotCached)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Default equality check using strict equality.
|
|
48
|
+
*/
|
|
49
|
+
function defaultIsEqual<T>(a: T, b: T): boolean {
|
|
50
|
+
return a === b
|
|
51
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react'
|
|
2
|
+
import type { UndoState } from '@contember/bindx'
|
|
3
|
+
import { useBindxContext } from './BackendAdapterContext.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Result type for useUndo hook.
|
|
7
|
+
*/
|
|
8
|
+
export interface UndoHookResult {
|
|
9
|
+
/** Whether undo is available */
|
|
10
|
+
canUndo: boolean
|
|
11
|
+
/** Whether redo is available */
|
|
12
|
+
canRedo: boolean
|
|
13
|
+
/** Whether undo/redo is currently blocked (e.g., during persist) */
|
|
14
|
+
isBlocked: boolean
|
|
15
|
+
/** Number of entries in undo stack */
|
|
16
|
+
undoCount: number
|
|
17
|
+
/** Number of entries in redo stack */
|
|
18
|
+
redoCount: number
|
|
19
|
+
/** Undo the last action or group */
|
|
20
|
+
undo: () => void
|
|
21
|
+
/** Redo the last undone action or group */
|
|
22
|
+
redo: () => void
|
|
23
|
+
/** Begin a manual group - all actions until endGroup are grouped as one undo entry */
|
|
24
|
+
beginGroup: (label?: string) => string
|
|
25
|
+
/** End a manual group */
|
|
26
|
+
endGroup: (groupId: string) => void
|
|
27
|
+
/** Clear all undo/redo history */
|
|
28
|
+
clear: () => void
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hook to access undo/redo functionality.
|
|
33
|
+
* Must be used within a BindxProvider with enableUndo={true}.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* function Editor() {
|
|
38
|
+
* const { canUndo, canRedo, undo, redo } = useUndo()
|
|
39
|
+
*
|
|
40
|
+
* return (
|
|
41
|
+
* <div>
|
|
42
|
+
* <button onClick={undo} disabled={!canUndo}>Undo</button>
|
|
43
|
+
* <button onClick={redo} disabled={!canRedo}>Redo</button>
|
|
44
|
+
* </div>
|
|
45
|
+
* )
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function useUndo(): UndoHookResult {
|
|
50
|
+
const { undoManager } = useBindxContext()
|
|
51
|
+
|
|
52
|
+
if (!undoManager) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
'useUndo: Undo is not enabled. Set enableUndo={true} on BindxProvider.',
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Subscribe to undo manager state changes
|
|
59
|
+
const state = useSyncExternalStore(
|
|
60
|
+
useCallback((callback) => undoManager.subscribe(callback), [undoManager]),
|
|
61
|
+
useCallback(() => undoManager.getState(), [undoManager]),
|
|
62
|
+
useCallback(() => undoManager.getState(), [undoManager]),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const undo = useCallback(() => undoManager.undo(), [undoManager])
|
|
66
|
+
const redo = useCallback(() => undoManager.redo(), [undoManager])
|
|
67
|
+
const beginGroup = useCallback((label?: string) => undoManager.beginGroup(label), [undoManager])
|
|
68
|
+
const endGroup = useCallback((id: string) => undoManager.endGroup(id), [undoManager])
|
|
69
|
+
const clear = useCallback(() => undoManager.clear(), [undoManager])
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
canUndo: state.canUndo,
|
|
73
|
+
canRedo: state.canRedo,
|
|
74
|
+
isBlocked: state.isBlocked,
|
|
75
|
+
undoCount: state.undoCount,
|
|
76
|
+
redoCount: state.redoCount,
|
|
77
|
+
undo,
|
|
78
|
+
redo,
|
|
79
|
+
beginGroup,
|
|
80
|
+
endGroup,
|
|
81
|
+
clear,
|
|
82
|
+
}
|
|
83
|
+
}
|