@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,383 @@
|
|
|
1
|
+
import React, { memo, useCallback, useEffect, useMemo, useRef, useSyncExternalStore, type ReactElement } from 'react'
|
|
2
|
+
import { useBindxContext, useSchemaRegistry } from '../../hooks/BackendAdapterContext.js'
|
|
3
|
+
import { useEntity } from '../../hooks/useEntity.js'
|
|
4
|
+
import { useSelectionCollection } from '../../hooks/useSelectionCollection.js'
|
|
5
|
+
import type { EntityAccessor, SelectionMeta } from '../types.js'
|
|
6
|
+
import { type EntityDef, type EntityUniqueWhere, type AnyBrand, type FieldError, EntityHandle, type SnapshotStore, type ActionDispatcher, type SchemaRegistry, type CommonEntity } from '@contember/bindx'
|
|
7
|
+
|
|
8
|
+
// ==================== Props Types ====================
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Base props shared by both edit and create modes.
|
|
12
|
+
* TRoleMap is the role map from EntityDef — entity type resolved as CommonEntity<TRoleMap>.
|
|
13
|
+
*/
|
|
14
|
+
interface EntityBaseProps<TRoleMap extends Record<string, object>> {
|
|
15
|
+
/** Entity definition reference */
|
|
16
|
+
entity: EntityDef<TRoleMap>
|
|
17
|
+
/** Render function receiving typed entity accessor with direct field access */
|
|
18
|
+
children: (entity: EntityAccessor<CommonEntity<TRoleMap>>) => React.ReactNode
|
|
19
|
+
/** Error fallback */
|
|
20
|
+
error?: (error: FieldError) => React.ReactNode
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Props for editing an existing entity (fetched by unique field)
|
|
25
|
+
*/
|
|
26
|
+
interface EntityByProps<TRoleMap extends Record<string, object>> extends EntityBaseProps<TRoleMap> {
|
|
27
|
+
/** Unique field(s) to identify the entity (e.g., { id: '...' } or { slug: '...' }) */
|
|
28
|
+
by: EntityUniqueWhere
|
|
29
|
+
create?: never
|
|
30
|
+
onPersisted?: never
|
|
31
|
+
/** Loading fallback */
|
|
32
|
+
loading?: React.ReactNode
|
|
33
|
+
/** Not found fallback */
|
|
34
|
+
notFound?: React.ReactNode
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Props for creating a new entity
|
|
39
|
+
*/
|
|
40
|
+
interface EntityCreateProps<TRoleMap extends Record<string, object>> extends EntityBaseProps<TRoleMap> {
|
|
41
|
+
by?: never
|
|
42
|
+
/** Create a new entity instead of fetching an existing one */
|
|
43
|
+
create: true
|
|
44
|
+
/** Callback when entity is persisted and receives server-assigned ID */
|
|
45
|
+
onPersisted?: (id: string) => void
|
|
46
|
+
loading?: never
|
|
47
|
+
notFound?: never
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Props for Entity component - union of edit mode (by) and create mode
|
|
52
|
+
*/
|
|
53
|
+
export type EntityProps<TRoleMap extends Record<string, object> = Record<string, object>> =
|
|
54
|
+
| EntityByProps<TRoleMap>
|
|
55
|
+
| EntityCreateProps<TRoleMap>
|
|
56
|
+
|
|
57
|
+
// ==================== Internal Props Types ====================
|
|
58
|
+
|
|
59
|
+
interface EntityByModeProps {
|
|
60
|
+
entityType: string
|
|
61
|
+
by: EntityUniqueWhere
|
|
62
|
+
children: (entity: EntityAccessor<unknown>) => React.ReactNode
|
|
63
|
+
loading?: React.ReactNode
|
|
64
|
+
error?: (error: FieldError) => React.ReactNode
|
|
65
|
+
notFound?: React.ReactNode
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface EntityCreateModeProps {
|
|
69
|
+
entityType: string
|
|
70
|
+
children: (entity: EntityAccessor<unknown>) => React.ReactNode
|
|
71
|
+
error?: (error: FieldError) => React.ReactNode
|
|
72
|
+
onPersisted?: (id: string) => void
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ==================== EntityByMode Component ====================
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Internal component for edit mode (fetching existing entity by unique field)
|
|
79
|
+
*/
|
|
80
|
+
function EntityByMode({
|
|
81
|
+
entityType,
|
|
82
|
+
by,
|
|
83
|
+
children,
|
|
84
|
+
loading,
|
|
85
|
+
error: errorFallback,
|
|
86
|
+
notFound,
|
|
87
|
+
}: EntityByModeProps): ReactElement | null {
|
|
88
|
+
const { store, dispatcher } = useBindxContext()
|
|
89
|
+
const schemaRegistry = useSchemaRegistry()
|
|
90
|
+
|
|
91
|
+
// Stable key for the 'by' clause
|
|
92
|
+
const byKey = useMemo(() => JSON.stringify(by), [by])
|
|
93
|
+
|
|
94
|
+
// Phase 1: Collect JSX selection
|
|
95
|
+
const { selection, queryKey } = useSelectionCollection({
|
|
96
|
+
entityType,
|
|
97
|
+
depsKey: byKey,
|
|
98
|
+
collect: collector => children(collector as EntityAccessor<unknown>),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// Phase 2: Load data using unified hook
|
|
102
|
+
const result = useEntity({ $name: entityType } as EntityDef, {
|
|
103
|
+
by,
|
|
104
|
+
selection,
|
|
105
|
+
queryKey,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Render based on status
|
|
109
|
+
if (result.status === 'loading') {
|
|
110
|
+
return <>{loading ?? <DefaultLoading />}</>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (result.status === 'error') {
|
|
114
|
+
if (errorFallback) {
|
|
115
|
+
return <>{errorFallback(result.error)}</>
|
|
116
|
+
}
|
|
117
|
+
return <DefaultError error={result.error} />
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (result.status === 'not_found') {
|
|
121
|
+
return <>{notFound ?? <DefaultNotFound entityType={entityType} by={by} />}</>
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Phase 3: Runtime render with EntityHandle (selection-aware)
|
|
125
|
+
return (
|
|
126
|
+
<EntityHandleRenderer
|
|
127
|
+
entityId={result.id}
|
|
128
|
+
entityType={entityType}
|
|
129
|
+
store={store}
|
|
130
|
+
dispatcher={dispatcher}
|
|
131
|
+
schemaRegistry={schemaRegistry}
|
|
132
|
+
selection={selection}
|
|
133
|
+
children={children}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ==================== EntityCreateMode Component ====================
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Snapshot type for create mode subscription
|
|
142
|
+
*/
|
|
143
|
+
interface CreateModeSnapshot {
|
|
144
|
+
version: number
|
|
145
|
+
persistedId: string | null
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Internal component for create mode (creating a new entity)
|
|
150
|
+
*/
|
|
151
|
+
function EntityCreateMode({
|
|
152
|
+
entityType,
|
|
153
|
+
children,
|
|
154
|
+
error: errorFallback,
|
|
155
|
+
onPersisted,
|
|
156
|
+
}: EntityCreateModeProps): ReactElement {
|
|
157
|
+
const { store, dispatcher } = useBindxContext()
|
|
158
|
+
const schemaRegistry = useSchemaRegistry()
|
|
159
|
+
const tempIdRef = useRef<string | null>(null)
|
|
160
|
+
|
|
161
|
+
// Create entity once on mount (using ref to ensure only one creation)
|
|
162
|
+
const tempId = useMemo(() => {
|
|
163
|
+
if (tempIdRef.current) {
|
|
164
|
+
return tempIdRef.current
|
|
165
|
+
}
|
|
166
|
+
const id = store.createEntity(entityType)
|
|
167
|
+
tempIdRef.current = id
|
|
168
|
+
return id
|
|
169
|
+
}, [entityType, store])
|
|
170
|
+
|
|
171
|
+
// Subscribe to store changes for this entity
|
|
172
|
+
const subscribe = useCallback(
|
|
173
|
+
(callback: () => void) => store.subscribeToEntity(entityType, tempId, callback),
|
|
174
|
+
[store, entityType, tempId],
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
// Cache ref for snapshot stability
|
|
178
|
+
const snapshotCacheRef = useRef<CreateModeSnapshot | null>(null)
|
|
179
|
+
|
|
180
|
+
const getSnapshot = useCallback((): CreateModeSnapshot => {
|
|
181
|
+
const entitySnapshot = store.getEntitySnapshot(entityType, tempId)
|
|
182
|
+
const persistedId = store.getPersistedId(entityType, tempId)
|
|
183
|
+
const version = entitySnapshot?.version ?? 0
|
|
184
|
+
|
|
185
|
+
// Return cached snapshot if values haven't changed
|
|
186
|
+
const cached = snapshotCacheRef.current
|
|
187
|
+
if (cached && cached.version === version && cached.persistedId === persistedId) {
|
|
188
|
+
return cached
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Create new snapshot and cache it
|
|
192
|
+
const newSnapshot: CreateModeSnapshot = { version, persistedId }
|
|
193
|
+
snapshotCacheRef.current = newSnapshot
|
|
194
|
+
return newSnapshot
|
|
195
|
+
}, [store, entityType, tempId])
|
|
196
|
+
|
|
197
|
+
const { persistedId } = useSyncExternalStore(
|
|
198
|
+
subscribe,
|
|
199
|
+
getSnapshot,
|
|
200
|
+
getSnapshot, // Server snapshot same as client for create mode
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
// Track previous persistedId to call onPersisted only once
|
|
204
|
+
const prevPersistedIdRef = useRef<string | null>(null)
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (persistedId && persistedId !== prevPersistedIdRef.current) {
|
|
208
|
+
prevPersistedIdRef.current = persistedId
|
|
209
|
+
onPersisted?.(persistedId)
|
|
210
|
+
}
|
|
211
|
+
}, [persistedId, onPersisted])
|
|
212
|
+
|
|
213
|
+
// Selection collection still works for building mutations
|
|
214
|
+
useSelectionCollection({
|
|
215
|
+
entityType,
|
|
216
|
+
depsKey: tempId,
|
|
217
|
+
collect: collector => children(collector as EntityAccessor<unknown>),
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
// Create EntityHandle (no selection for create mode - all fields accessible)
|
|
221
|
+
return (
|
|
222
|
+
<EntityHandleRenderer
|
|
223
|
+
entityId={tempId}
|
|
224
|
+
entityType={entityType}
|
|
225
|
+
store={store}
|
|
226
|
+
dispatcher={dispatcher}
|
|
227
|
+
schemaRegistry={schemaRegistry}
|
|
228
|
+
children={children}
|
|
229
|
+
/>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ==================== EntityHandle Renderer ====================
|
|
234
|
+
|
|
235
|
+
interface EntityHandleRendererProps {
|
|
236
|
+
entityId: string
|
|
237
|
+
entityType: string
|
|
238
|
+
store: SnapshotStore
|
|
239
|
+
dispatcher: ActionDispatcher
|
|
240
|
+
schemaRegistry: SchemaRegistry
|
|
241
|
+
selection?: SelectionMeta
|
|
242
|
+
children: (entity: EntityAccessor<unknown>) => React.ReactNode
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Shared component that creates an EntityHandle and renders children with it.
|
|
247
|
+
* Used by both EntityByMode and EntityCreateMode.
|
|
248
|
+
*
|
|
249
|
+
* Subscribes to entity snapshot changes so that the handle reference changes
|
|
250
|
+
* when entity data changes. This is necessary because children may be wrapped
|
|
251
|
+
* in React.memo and need a new handle reference to trigger re-renders.
|
|
252
|
+
*/
|
|
253
|
+
function EntityHandleRenderer({
|
|
254
|
+
entityId,
|
|
255
|
+
entityType,
|
|
256
|
+
store,
|
|
257
|
+
dispatcher,
|
|
258
|
+
schemaRegistry,
|
|
259
|
+
selection,
|
|
260
|
+
children,
|
|
261
|
+
}: EntityHandleRendererProps): ReactElement {
|
|
262
|
+
const subscribe = useCallback(
|
|
263
|
+
(callback: () => void) => store.subscribeToEntity(entityType, entityId, callback),
|
|
264
|
+
[store, entityType, entityId],
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
const getSnapshot = useCallback(
|
|
268
|
+
() => store.getEntitySnapshot(entityType, entityId)?.version ?? 0,
|
|
269
|
+
[store, entityType, entityId],
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
const version = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
|
|
273
|
+
|
|
274
|
+
const handle = useMemo(
|
|
275
|
+
() => EntityHandle.create(entityId, entityType, store, dispatcher, schemaRegistry, undefined, selection),
|
|
276
|
+
[entityId, entityType, store, dispatcher, schemaRegistry, selection, version],
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
useEffect(() => () => { handle.dispose() }, [handle])
|
|
280
|
+
|
|
281
|
+
return <>{children(handle as unknown as EntityAccessor<unknown>)}</>
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ==================== Main Entity Component ====================
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Entity component - orchestrates the two-pass rendering approach.
|
|
288
|
+
*
|
|
289
|
+
* Supports two modes:
|
|
290
|
+
* - Edit mode: Fetches an existing entity by unique field(s)
|
|
291
|
+
* - Create mode: Creates a new entity locally
|
|
292
|
+
*
|
|
293
|
+
* @example Edit mode
|
|
294
|
+
* ```tsx
|
|
295
|
+
* <Entity entity={schema.Author} by={{ id: 'author-1' }}>
|
|
296
|
+
* {author => (
|
|
297
|
+
* <>
|
|
298
|
+
* <Field field={author.fields.name} />
|
|
299
|
+
* <HasMany field={author.fields.articles}>
|
|
300
|
+
* {article => <Field field={article.fields.title} />}
|
|
301
|
+
* </HasMany>
|
|
302
|
+
* </>
|
|
303
|
+
* )}
|
|
304
|
+
* </Entity>
|
|
305
|
+
* ```
|
|
306
|
+
*
|
|
307
|
+
* @example Create mode
|
|
308
|
+
* ```tsx
|
|
309
|
+
* <Entity entity={schema.Author} create onPersisted={id => navigate(`/authors/${id}`)}>
|
|
310
|
+
* {author => (
|
|
311
|
+
* <>
|
|
312
|
+
* <Field field={author.fields.name} />
|
|
313
|
+
* {author.isNew && <span>New author</span>}
|
|
314
|
+
* {author.persistedId && <span>Saved as {author.persistedId}</span>}
|
|
315
|
+
* </>
|
|
316
|
+
* )}
|
|
317
|
+
* </Entity>
|
|
318
|
+
* ```
|
|
319
|
+
*/
|
|
320
|
+
function EntityImpl<TRoleMap extends Record<string, object>>(
|
|
321
|
+
props: EntityProps<TRoleMap>,
|
|
322
|
+
): ReactElement | null {
|
|
323
|
+
const isCreateMode = 'create' in props && props.create === true
|
|
324
|
+
|
|
325
|
+
if (isCreateMode) {
|
|
326
|
+
const createProps = props as EntityCreateProps<TRoleMap>
|
|
327
|
+
return (
|
|
328
|
+
<EntityCreateMode
|
|
329
|
+
entityType={createProps.entity.$name}
|
|
330
|
+
children={createProps.children as (entity: EntityAccessor<unknown>) => React.ReactNode}
|
|
331
|
+
error={createProps.error}
|
|
332
|
+
onPersisted={createProps.onPersisted}
|
|
333
|
+
/>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const byProps = props as EntityByProps<TRoleMap>
|
|
338
|
+
return (
|
|
339
|
+
<EntityByMode
|
|
340
|
+
entityType={byProps.entity.$name}
|
|
341
|
+
by={byProps.by}
|
|
342
|
+
children={byProps.children as (entity: EntityAccessor<unknown>) => React.ReactNode}
|
|
343
|
+
loading={byProps.loading}
|
|
344
|
+
error={byProps.error}
|
|
345
|
+
notFound={byProps.notFound}
|
|
346
|
+
/>
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Note: Using type assertion for generic memo component
|
|
351
|
+
export const Entity = memo(EntityImpl) as unknown as typeof EntityImpl
|
|
352
|
+
|
|
353
|
+
// ==================== Default Fallback Components ====================
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Default loading component
|
|
357
|
+
*/
|
|
358
|
+
function DefaultLoading(): ReactElement {
|
|
359
|
+
return <div className="bindx-loading">Loading...</div>
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Default error component
|
|
364
|
+
*/
|
|
365
|
+
function DefaultError({ error }: { error: FieldError }): ReactElement {
|
|
366
|
+
return (
|
|
367
|
+
<div className="bindx-error">
|
|
368
|
+
<strong>Error:</strong> {error.message}
|
|
369
|
+
</div>
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Default not found component
|
|
375
|
+
*/
|
|
376
|
+
function DefaultNotFound({ entityType, by }: { entityType: string; by: EntityUniqueWhere }): ReactElement {
|
|
377
|
+
const byDescription = Object.entries(by).map(([k, v]) => `${k}="${v}"`).join(', ')
|
|
378
|
+
return (
|
|
379
|
+
<div className="bindx-not-found">
|
|
380
|
+
{entityType} with {byDescription} not found
|
|
381
|
+
</div>
|
|
382
|
+
)
|
|
383
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import React, { memo, type ReactElement } from 'react'
|
|
2
|
+
import { type EntityDef, type EntityWhere, type EntityOrderBy, type FieldError, type CommonEntity } from '@contember/bindx'
|
|
3
|
+
import { useEntityList } from '../../hooks/useEntityList.js'
|
|
4
|
+
import { useSelectionCollection } from '../../hooks/useSelectionCollection.js'
|
|
5
|
+
import type { EntityAccessor } from '../types.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Props for EntityList component
|
|
9
|
+
*/
|
|
10
|
+
export interface EntityListProps<TRoleMap extends Record<string, object> = Record<string, object>> {
|
|
11
|
+
/** Entity definition reference */
|
|
12
|
+
entity: EntityDef<TRoleMap>
|
|
13
|
+
/** Optional filter criteria - type-safe based on entity schema */
|
|
14
|
+
filter?: EntityWhere<CommonEntity<TRoleMap>>
|
|
15
|
+
/** Optional ordering - type-safe based on entity schema */
|
|
16
|
+
orderBy?: readonly EntityOrderBy<CommonEntity<TRoleMap>>[]
|
|
17
|
+
/** Optional limit */
|
|
18
|
+
limit?: number
|
|
19
|
+
/** Optional offset */
|
|
20
|
+
offset?: number
|
|
21
|
+
/** Render function receiving typed entity accessor with direct field access */
|
|
22
|
+
children: (entity: EntityAccessor<CommonEntity<TRoleMap>>, index: number) => React.ReactNode
|
|
23
|
+
/** Loading fallback */
|
|
24
|
+
loading?: React.ReactNode
|
|
25
|
+
/** Error fallback */
|
|
26
|
+
error?: (error: FieldError) => React.ReactNode
|
|
27
|
+
/** Empty state fallback (when list has no items) */
|
|
28
|
+
empty?: React.ReactNode
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* EntityList component - orchestrates the two-pass rendering approach for lists.
|
|
33
|
+
*
|
|
34
|
+
* Phase 1 (Collection): Renders children once with collector proxy to determine which fields are needed
|
|
35
|
+
* Phase 2 (Loading): Fetches data based on collected selection
|
|
36
|
+
* Phase 3 (Runtime): Renders children for each item with real data accessors
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* <EntityList entity={schema.Article} filter={{ published: true }} orderBy={[{ createdAt: 'desc' }]} limit={10}>
|
|
41
|
+
* {(article, index) => (
|
|
42
|
+
* <div key={article.id}>
|
|
43
|
+
* <Field field={article.fields.title} />
|
|
44
|
+
* </div>
|
|
45
|
+
* )}
|
|
46
|
+
* </EntityList>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
function EntityListComponent<TRoleMap extends Record<string, object>>({
|
|
50
|
+
entity,
|
|
51
|
+
filter,
|
|
52
|
+
orderBy,
|
|
53
|
+
limit,
|
|
54
|
+
offset,
|
|
55
|
+
children,
|
|
56
|
+
loading,
|
|
57
|
+
error: errorFallback,
|
|
58
|
+
empty,
|
|
59
|
+
}: EntityListProps<TRoleMap>): ReactElement | null {
|
|
60
|
+
const entityType = entity.$name
|
|
61
|
+
|
|
62
|
+
// Stable options key for dependency tracking
|
|
63
|
+
const optionsKey = JSON.stringify({
|
|
64
|
+
filter: filter ?? {},
|
|
65
|
+
orderBy: orderBy ?? [],
|
|
66
|
+
limit,
|
|
67
|
+
offset,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// Phase 1: Collect JSX selection
|
|
71
|
+
const { selection, queryKey } = useSelectionCollection({
|
|
72
|
+
entityType,
|
|
73
|
+
depsKey: optionsKey,
|
|
74
|
+
collect: collector => children(collector as unknown as EntityAccessor<CommonEntity<TRoleMap>>, 0),
|
|
75
|
+
queryKeyExtra: { filter, orderBy, limit, offset },
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Phase 2: Load data using unified hook
|
|
79
|
+
const result = useEntityList(entity, {
|
|
80
|
+
filter,
|
|
81
|
+
orderBy,
|
|
82
|
+
limit,
|
|
83
|
+
offset,
|
|
84
|
+
selection,
|
|
85
|
+
queryKey,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Render based on status
|
|
89
|
+
if (result.status === 'loading') {
|
|
90
|
+
return <>{loading ?? <DefaultLoading />}</>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (result.status === 'error') {
|
|
94
|
+
if (errorFallback) {
|
|
95
|
+
return <>{errorFallback(result.error)}</>
|
|
96
|
+
}
|
|
97
|
+
return <DefaultError error={result.error} />
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Empty state
|
|
101
|
+
if (result.items.length === 0) {
|
|
102
|
+
return <>{empty ?? <DefaultEmpty entityType={entityType} />}</>
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Phase 3: Runtime render — items are already EntityAccessors from the hook
|
|
106
|
+
const items = result.items.map((item, index) => {
|
|
107
|
+
return (
|
|
108
|
+
<React.Fragment key={item.id}>
|
|
109
|
+
{children(item as unknown as EntityAccessor<CommonEntity<TRoleMap>>, index)}
|
|
110
|
+
</React.Fragment>
|
|
111
|
+
)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return <>{items}</>
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Note: Using type assertion for generic memo component
|
|
118
|
+
export const EntityList = memo(EntityListComponent) as unknown as typeof EntityListComponent
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Default loading component
|
|
122
|
+
*/
|
|
123
|
+
function DefaultLoading(): ReactElement {
|
|
124
|
+
return <div className="bindx-loading">Loading...</div>
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Default error component
|
|
129
|
+
*/
|
|
130
|
+
function DefaultError({ error }: { error: FieldError }): ReactElement {
|
|
131
|
+
return (
|
|
132
|
+
<div className="bindx-error">
|
|
133
|
+
<strong>Error:</strong> {error.message}
|
|
134
|
+
</div>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Default empty component
|
|
140
|
+
*/
|
|
141
|
+
function DefaultEmpty({ entityType }: { entityType: string }): ReactElement {
|
|
142
|
+
return (
|
|
143
|
+
<div className="bindx-empty">
|
|
144
|
+
No {entityType} items found
|
|
145
|
+
</div>
|
|
146
|
+
)
|
|
147
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { memo, type ReactElement } from 'react'
|
|
2
|
+
import type { FieldProps, SelectionFieldMeta, SelectionProvider, FieldRef } from '../types.js'
|
|
3
|
+
import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Field component - renders a scalar field value
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* // Basic usage - renders value as string
|
|
11
|
+
* <Field field={entity.fields.name} />
|
|
12
|
+
*
|
|
13
|
+
* // With custom render function
|
|
14
|
+
* <Field field={entity.fields.email}>
|
|
15
|
+
* {field => <a href={`mailto:${field.value}`}>{field.value}</a>}
|
|
16
|
+
* </Field>
|
|
17
|
+
*
|
|
18
|
+
* // With format function
|
|
19
|
+
* <Field field={entity.fields.date} format={d => d?.toLocaleDateString()} />
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
function FieldImpl<T>({ field, children, format }: FieldProps<T>): ReactElement | null {
|
|
23
|
+
// Handle undefined field (e.g., when accessing field on disconnected has-one relation)
|
|
24
|
+
if (field === undefined || field === null) {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// At runtime, field is always a full FieldRef (proxy provides all properties)
|
|
29
|
+
// Props accept FieldRefBase for type compatibility with both implicit and explicit modes
|
|
30
|
+
const fullField = field as FieldRef<T>
|
|
31
|
+
|
|
32
|
+
if (children) {
|
|
33
|
+
return <>{children(fullField)}</>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (format) {
|
|
37
|
+
return <>{format(fullField.value)}</>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Default: render value as string
|
|
41
|
+
if (fullField.value === null || fullField.value === undefined) {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return <>{String(fullField.value)}</>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const Field = memo(FieldImpl) as typeof FieldImpl
|
|
49
|
+
|
|
50
|
+
// Static method for selection extraction
|
|
51
|
+
const fieldWithSelection = Field as typeof Field & SelectionProvider & { [BINDX_COMPONENT]: true }
|
|
52
|
+
|
|
53
|
+
fieldWithSelection.getSelection = (props: FieldProps<unknown>): SelectionFieldMeta | null => {
|
|
54
|
+
// Handle undefined field (e.g., when accessing field on disconnected has-one relation)
|
|
55
|
+
if (props.field === undefined || props.field === null) {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
const meta = props.field[FIELD_REF_META]
|
|
59
|
+
if (!meta) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
// During collection phase, field accesses are already tracked in the scope tree via proxy.
|
|
63
|
+
// Field.getSelection should not duplicate them as flat entries which causes incorrect queries.
|
|
64
|
+
// Collection-phase refs have empty entityId.
|
|
65
|
+
if (meta.entityId === '') {
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
fieldName: meta.fieldName,
|
|
70
|
+
alias: meta.fieldName,
|
|
71
|
+
path: meta.path,
|
|
72
|
+
isArray: false,
|
|
73
|
+
isRelation: false,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fieldWithSelection[BINDX_COMPONENT] = true
|
|
78
|
+
|
|
79
|
+
export { fieldWithSelection as FieldWithMeta }
|