@contember/bindx-react 0.1.23 → 0.1.25
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/hooks/index.d.ts +1 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useAccessor.d.ts +16 -0
- package/dist/hooks/useAccessor.d.ts.map +1 -0
- package/dist/hooks/useField.d.ts +1 -4
- package/dist/hooks/useField.d.ts.map +1 -1
- package/dist/hooks/useHasMany.d.ts +2 -5
- package/dist/hooks/useHasMany.d.ts.map +1 -1
- package/dist/hooks/useHasOne.d.ts +2 -5
- package/dist/hooks/useHasOne.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/jsx/analyzer.d.ts.map +1 -1
- package/dist/jsx/componentBuilder.types.d.ts +16 -12
- package/dist/jsx/componentBuilder.types.d.ts.map +1 -1
- package/dist/jsx/componentFactory.d.ts.map +1 -1
- package/dist/jsx/components/Attribute.d.ts +38 -0
- package/dist/jsx/components/Attribute.d.ts.map +1 -0
- package/dist/jsx/components/Entity.d.ts +2 -2
- package/dist/jsx/components/Entity.d.ts.map +1 -1
- package/dist/jsx/components/EntityList.d.ts.map +1 -1
- package/dist/jsx/components/Field.d.ts.map +1 -1
- package/dist/jsx/components/HasMany.d.ts.map +1 -1
- package/dist/jsx/components/HasOne.d.ts.map +1 -1
- package/dist/jsx/components/index.d.ts +1 -0
- package/dist/jsx/components/index.d.ts.map +1 -1
- package/dist/jsx/devAnnotations.d.ts +12 -0
- package/dist/jsx/devAnnotations.d.ts.map +1 -0
- package/dist/jsx/index.d.ts +1 -0
- package/dist/jsx/index.d.ts.map +1 -1
- package/dist/jsx/types.d.ts +3 -3
- package/dist/jsx/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAccessor.ts +64 -0
- package/src/hooks/useField.ts +4 -20
- package/src/hooks/useHasMany.ts +4 -17
- package/src/hooks/useHasOne.ts +4 -17
- package/src/index.ts +3 -0
- package/src/jsx/analyzer.ts +1 -84
- package/src/jsx/componentBuilder.types.ts +31 -16
- package/src/jsx/componentFactory.ts +47 -5
- package/src/jsx/components/Attribute.tsx +90 -0
- package/src/jsx/components/Entity.tsx +5 -3
- package/src/jsx/components/EntityList.tsx +3 -1
- package/src/jsx/components/Field.tsx +11 -3
- package/src/jsx/components/HasMany.tsx +10 -1
- package/src/jsx/components/HasOne.tsx +5 -1
- package/src/jsx/components/index.ts +3 -0
- package/src/jsx/devAnnotations.ts +33 -0
- package/src/jsx/index.ts +1 -0
- package/src/jsx/types.ts +3 -3
package/src/hooks/useHasMany.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { useSnapshotStore } from './BackendAdapterContext.js'
|
|
4
|
-
import type { AnyBrand } from '@contember/bindx'
|
|
1
|
+
import { type HasManyRef, type HasManyAccessor, type AnyBrand } from '@contember/bindx'
|
|
2
|
+
import { useAccessor } from './useAccessor.js'
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* Subscribes to a has-many ref and returns a HasManyAccessor with live collection access.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
* this hook adds a store subscription so the component re-renders on changes,
|
|
11
|
-
* and widens the type to HasManyAccessor.
|
|
7
|
+
* Thin wrapper over useAccessor for ergonomics.
|
|
12
8
|
*/
|
|
13
9
|
export function useHasMany<
|
|
14
10
|
TEntity,
|
|
@@ -17,14 +13,5 @@ export function useHasMany<
|
|
|
17
13
|
TEntityName extends string = string,
|
|
18
14
|
TSchema extends Record<string, object> = Record<string, object>,
|
|
19
15
|
>(ref: HasManyRef<TEntity, TSelected, TBrand, TEntityName, TSchema>): HasManyAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema> {
|
|
20
|
-
|
|
21
|
-
const meta = ref[FIELD_REF_META]
|
|
22
|
-
|
|
23
|
-
useSyncExternalStore(
|
|
24
|
-
(callback) => store.subscribeToEntity(meta.entityType, meta.entityId, callback),
|
|
25
|
-
() => store.getVersion(),
|
|
26
|
-
() => store.getVersion(),
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
return ref as unknown as HasManyAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>
|
|
16
|
+
return useAccessor(ref)
|
|
30
17
|
}
|
package/src/hooks/useHasOne.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { useSnapshotStore } from './BackendAdapterContext.js'
|
|
4
|
-
import type { AnyBrand } from '@contember/bindx'
|
|
1
|
+
import { type HasOneRef, type HasOneAccessor, type AnyBrand } from '@contember/bindx'
|
|
2
|
+
import { useAccessor } from './useAccessor.js'
|
|
5
3
|
|
|
6
4
|
/**
|
|
7
5
|
* Subscribes to a has-one ref and returns a HasOneAccessor with live state access.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
* this hook adds a store subscription so the component re-renders on changes,
|
|
11
|
-
* and widens the type to HasOneAccessor.
|
|
7
|
+
* Thin wrapper over useAccessor for ergonomics.
|
|
12
8
|
*/
|
|
13
9
|
export function useHasOne<
|
|
14
10
|
TEntity,
|
|
@@ -17,14 +13,5 @@ export function useHasOne<
|
|
|
17
13
|
TEntityName extends string = string,
|
|
18
14
|
TSchema extends Record<string, object> = Record<string, object>,
|
|
19
15
|
>(ref: HasOneRef<TEntity, TSelected, TBrand, TEntityName, TSchema>): HasOneAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema> {
|
|
20
|
-
|
|
21
|
-
const meta = ref[FIELD_REF_META]
|
|
22
|
-
|
|
23
|
-
useSyncExternalStore(
|
|
24
|
-
(callback) => store.subscribeToEntity(meta.entityType, meta.entityId, callback),
|
|
25
|
-
() => store.getVersion(),
|
|
26
|
-
() => store.getVersion(),
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
return ref as unknown as HasOneAccessor<TEntity, TSelected, TBrand, TEntityName, TSchema>
|
|
16
|
+
return useAccessor(ref)
|
|
30
17
|
}
|
package/src/index.ts
CHANGED
|
@@ -297,6 +297,7 @@ export {
|
|
|
297
297
|
useInterceptEntity,
|
|
298
298
|
useInterceptField,
|
|
299
299
|
// Ref → Accessor hooks
|
|
300
|
+
useAccessor,
|
|
300
301
|
useField,
|
|
301
302
|
useHasMany,
|
|
302
303
|
useHasOne,
|
|
@@ -314,6 +315,8 @@ export type { UndoHookResult } from './hooks/useUndo.js'
|
|
|
314
315
|
|
|
315
316
|
// JSX Components
|
|
316
317
|
export {
|
|
318
|
+
Attribute,
|
|
319
|
+
type AttributeProps,
|
|
317
320
|
Field,
|
|
318
321
|
HasMany,
|
|
319
322
|
HasOne,
|
package/src/jsx/analyzer.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import type { ReactNode, ReactElement } from 'react'
|
|
2
2
|
import { isValidElement } from 'react'
|
|
3
3
|
import type { SelectionMeta, SelectionFieldMeta, SelectionProvider } from './types.js'
|
|
4
|
-
import { FIELD_REF_META, SCOPE_REF } from './types.js'
|
|
5
4
|
import { SelectionMetaCollector, mergeSelections } from './SelectionMeta.js'
|
|
6
|
-
import { SelectionScope } from '@contember/bindx'
|
|
7
|
-
import { isBindxComponent, COMPONENT_SELECTIONS } from './createComponent.js'
|
|
8
5
|
|
|
9
6
|
/**
|
|
10
7
|
* Analyzes JSX tree to extract field selection metadata.
|
|
@@ -47,24 +44,7 @@ export function analyzeJsx(node: ReactNode, selection: SelectionMetaCollector):
|
|
|
47
44
|
// Handle function components
|
|
48
45
|
const component = element.type as unknown
|
|
49
46
|
|
|
50
|
-
// Check if it's a
|
|
51
|
-
if (isBindxComponent(component)) {
|
|
52
|
-
// First, trigger implicit collection if the component has getSelection
|
|
53
|
-
// This ensures lazy implicit selections are collected before we access COMPONENT_SELECTIONS
|
|
54
|
-
const componentObj = component as Record<string, unknown>
|
|
55
|
-
if ('getSelection' in componentObj && typeof componentObj['getSelection'] === 'function') {
|
|
56
|
-
;(component as SelectionProvider).getSelection(element.props as Record<string, unknown>, () => new SelectionMetaCollector())
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
handleBindxComponent(
|
|
60
|
-
component as { [COMPONENT_SELECTIONS]: Map<string, { selection: SelectionMeta }> },
|
|
61
|
-
element.props as Record<string, unknown>,
|
|
62
|
-
selection,
|
|
63
|
-
)
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Check if it's a component with getSelection (memo() wrapped or plain function components)
|
|
47
|
+
// Check if it's a component with getSelection (createComponent, Field, HasOne, HasMany, If, Show, etc.)
|
|
68
48
|
if (
|
|
69
49
|
component !== null &&
|
|
70
50
|
(typeof component === 'object' || typeof component === 'function') &&
|
|
@@ -166,69 +146,6 @@ export function convertToQuerySelection(jsxSelection: SelectionMeta): Record<str
|
|
|
166
146
|
return result
|
|
167
147
|
}
|
|
168
148
|
|
|
169
|
-
/**
|
|
170
|
-
* Handles bindx component in JSX analysis.
|
|
171
|
-
* Merges the component's pre-computed selection into the parent selection,
|
|
172
|
-
* adjusted to the correct path context based on the entity prop.
|
|
173
|
-
*/
|
|
174
|
-
function handleBindxComponent(
|
|
175
|
-
component: { [COMPONENT_SELECTIONS]: Map<string, { selection: SelectionMeta }> },
|
|
176
|
-
props: Record<string, unknown>,
|
|
177
|
-
parentSelection: SelectionMetaCollector,
|
|
178
|
-
): void {
|
|
179
|
-
const entityPropsMap = component[COMPONENT_SELECTIONS]
|
|
180
|
-
// For each entity prop in the fragment component
|
|
181
|
-
for (const [propName, meta] of entityPropsMap) {
|
|
182
|
-
const propValue = props[propName]
|
|
183
|
-
|
|
184
|
-
if (!propValue || typeof propValue !== 'object') {
|
|
185
|
-
continue
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Case 1: Prop has SCOPE_REF - direct reference to SelectionScope
|
|
189
|
-
// This is the case when passing relation.entity to a nested component
|
|
190
|
-
if (SCOPE_REF in propValue) {
|
|
191
|
-
const targetScope = (propValue as { [SCOPE_REF]: SelectionScope })[SCOPE_REF]
|
|
192
|
-
// Merge the nested component's selection directly into the relation's scope
|
|
193
|
-
targetScope.mergeFromSelectionMeta(meta.selection)
|
|
194
|
-
}
|
|
195
|
-
// Case 2: Prop is a FieldRef from a relation (e.g., article.fields.author)
|
|
196
|
-
// This has FIELD_REF_META with a path to adjust
|
|
197
|
-
else if (FIELD_REF_META in propValue) {
|
|
198
|
-
const refMeta = (propValue as { [FIELD_REF_META]: { path: string[]; fieldName: string } })[FIELD_REF_META]
|
|
199
|
-
|
|
200
|
-
// Add fields with adjusted paths to parent selection
|
|
201
|
-
for (const [_key, field] of meta.selection.fields) {
|
|
202
|
-
// Only process root-level fields from the fragment
|
|
203
|
-
if (field.path.length === 1) {
|
|
204
|
-
// Create a new field meta with the path adjusted to the parent context
|
|
205
|
-
const adjustedField: SelectionFieldMeta = {
|
|
206
|
-
...field,
|
|
207
|
-
path: [...refMeta.path, ...field.path],
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// If the field has nested selection, we need to preserve it
|
|
211
|
-
if (field.nested) {
|
|
212
|
-
adjustedField.nested = field.nested
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
parentSelection.addField(adjustedField)
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
// Case 3: Prop is an EntityRef from Entity children callback (root level)
|
|
220
|
-
// This doesn't have FIELD_REF_META, just merge the fields directly
|
|
221
|
-
else if ('id' in propValue && 'fields' in propValue) {
|
|
222
|
-
// This is an EntityRef - merge fragment's selection directly
|
|
223
|
-
for (const [_key, field] of meta.selection.fields) {
|
|
224
|
-
if (field.path.length === 1) {
|
|
225
|
-
parentSelection.addField({ ...field })
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
149
|
/**
|
|
233
150
|
* Debug helper - prints selection tree
|
|
234
151
|
*/
|
|
@@ -222,20 +222,32 @@ export type SetScalarProps<
|
|
|
222
222
|
// ============================================================================
|
|
223
223
|
|
|
224
224
|
/**
|
|
225
|
-
* Build
|
|
226
|
-
*
|
|
227
|
-
* For explicit selection (selector provided): Returns EntityAccessor with full access
|
|
228
|
-
* - Allows direct field access: `entity.fieldName.value`
|
|
229
|
-
* - Allows relation access: `entity.relation.length`, `entity.relation.$entity`
|
|
230
|
-
*
|
|
231
|
-
* For implicit selection (no selector): Returns EntityRef with restricted access
|
|
232
|
-
* - Blocks direct field access to enforce declarative patterns
|
|
233
|
-
* - Users must use components: `<Field field={entity.fieldName} />`
|
|
234
|
-
* - Users must use condition DSL: `<If condition={cond.hasItems(entity.relation)} />`
|
|
225
|
+
* Build public entity props — always EntityRef (stable pointer, no live data).
|
|
226
|
+
* Used for the component's external React props.
|
|
235
227
|
*/
|
|
236
228
|
export type BuildEntityProps<
|
|
237
229
|
TEntityProps extends Record<string, AnyEntityPropConfig>,
|
|
238
230
|
TRoles extends readonly string[],
|
|
231
|
+
> = {
|
|
232
|
+
readonly [K in keyof TEntityProps]: TEntityProps[K] extends EntityPropConfig<
|
|
233
|
+
infer TEntity,
|
|
234
|
+
infer TSelected,
|
|
235
|
+
infer _TIsImplicit
|
|
236
|
+
>
|
|
237
|
+
? EntityRef<TEntity, TSelected, AnyBrand, string>
|
|
238
|
+
: TEntityProps[K] extends InterfaceEntityPropConfig<infer TInterface, infer _TIsImplicit>
|
|
239
|
+
? EntityRef<TInterface, TInterface, AnyBrand, string>
|
|
240
|
+
: never
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Build render entity props — explicit selection → EntityAccessor (live data),
|
|
245
|
+
* implicit selection → EntityRef (pointer only, use <Field> components).
|
|
246
|
+
* Used for the render function's props type.
|
|
247
|
+
*/
|
|
248
|
+
export type BuildRenderEntityProps<
|
|
249
|
+
TEntityProps extends Record<string, AnyEntityPropConfig>,
|
|
250
|
+
TRoles extends readonly string[],
|
|
239
251
|
> = {
|
|
240
252
|
readonly [K in keyof TEntityProps]: TEntityProps[K] extends EntityPropConfig<
|
|
241
253
|
infer TEntity,
|
|
@@ -243,26 +255,29 @@ export type BuildEntityProps<
|
|
|
243
255
|
infer TIsImplicit
|
|
244
256
|
>
|
|
245
257
|
? TIsImplicit extends true
|
|
246
|
-
// Implicit selection -> restricted EntityRef (no .value, .length access)
|
|
247
258
|
? EntityRef<TEntity, TSelected, AnyBrand, string>
|
|
248
|
-
// Explicit selection -> full EntityAccessor (full access)
|
|
249
259
|
: EntityAccessor<TEntity, TSelected, AnyBrand, string>
|
|
250
260
|
: TEntityProps[K] extends InterfaceEntityPropConfig<infer TInterface, infer TIsImplicit>
|
|
251
261
|
? TIsImplicit extends true
|
|
252
|
-
// Implicit interface -> restricted
|
|
253
262
|
? EntityRef<TInterface, TInterface, AnyBrand, string>
|
|
254
|
-
// Explicit interface -> full access
|
|
255
263
|
: EntityAccessor<TInterface, TInterface, AnyBrand, string>
|
|
256
264
|
: never
|
|
257
265
|
}
|
|
258
266
|
|
|
259
267
|
/**
|
|
260
|
-
* Build complete props type from builder state.
|
|
268
|
+
* Build complete public props type from builder state.
|
|
261
269
|
*/
|
|
262
270
|
export type BuildProps<TState extends ComponentBuilderState> =
|
|
263
271
|
TState['__scalarProps'] &
|
|
264
272
|
BuildEntityProps<TState['__entityProps'], TState['__roles']>
|
|
265
273
|
|
|
274
|
+
/**
|
|
275
|
+
* Build complete render props type from builder state.
|
|
276
|
+
*/
|
|
277
|
+
export type BuildRenderProps<TState extends ComponentBuilderState> =
|
|
278
|
+
TState['__scalarProps'] &
|
|
279
|
+
BuildRenderEntityProps<TState['__entityProps'], TState['__roles']>
|
|
280
|
+
|
|
266
281
|
/**
|
|
267
282
|
* Build fragment properties ($propName) from entity config.
|
|
268
283
|
*/
|
|
@@ -437,7 +452,7 @@ export interface ComponentBuilder<
|
|
|
437
452
|
* @param renderFn - React render function receiving typed props
|
|
438
453
|
* @returns Bindx component with fragment properties
|
|
439
454
|
*/
|
|
440
|
-
render(renderFn: (props:
|
|
455
|
+
render(renderFn: (props: BuildRenderProps<TState>) => ReactNode): BindxComponent<TState>
|
|
441
456
|
}
|
|
442
457
|
|
|
443
458
|
// ============================================================================
|
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
SelectionMeta,
|
|
16
16
|
SelectionBuilder,
|
|
17
17
|
AnyBrand,
|
|
18
|
+
EntityRef,
|
|
18
19
|
SchemaDefinition,
|
|
19
20
|
} from '@contember/bindx'
|
|
20
21
|
import {
|
|
@@ -28,10 +29,11 @@ import type {
|
|
|
28
29
|
SelectionPropMeta,
|
|
29
30
|
} from './componentBuilder.types.js'
|
|
30
31
|
import type { SelectionProvider, SelectionFieldMeta } from './types.js'
|
|
31
|
-
import { FIELD_REF_META, BINDX_COMPONENT } from './types.js'
|
|
32
|
+
import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from './types.js'
|
|
32
33
|
import { createCollectorProxy } from './proxy.js'
|
|
33
34
|
import { collectSelection } from './analyzer.js'
|
|
34
35
|
import { type Condition, evaluateCondition } from './conditions.js'
|
|
36
|
+
import { useAccessor } from '../hooks/useAccessor.js'
|
|
35
37
|
|
|
36
38
|
// ============================================================================
|
|
37
39
|
// Symbols
|
|
@@ -63,6 +65,24 @@ export interface EntityConfig {
|
|
|
63
65
|
readonly schema?: SchemaDefinition<Record<string, object>>
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
// ============================================================================
|
|
69
|
+
// Render Props Transformation
|
|
70
|
+
// ============================================================================
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Converts explicit entity ref props to accessors via useAccessor.
|
|
74
|
+
* Called with a fixed list of prop names — hook count is stable across renders.
|
|
75
|
+
*/
|
|
76
|
+
function useRenderProps<TProps extends object>(props: TProps, explicitPropNames: string[]): TProps {
|
|
77
|
+
const record = props as Record<string, unknown>
|
|
78
|
+
const accessors: Record<string, unknown> = {}
|
|
79
|
+
for (const name of explicitPropNames) {
|
|
80
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks -- stable iteration count (explicitPropNames is fixed at build time)
|
|
81
|
+
accessors[name] = useAccessor(record[name] as EntityRef<object>)
|
|
82
|
+
}
|
|
83
|
+
return { ...props, ...accessors } as TProps
|
|
84
|
+
}
|
|
85
|
+
|
|
66
86
|
// ============================================================================
|
|
67
87
|
// Component Building
|
|
68
88
|
// ============================================================================
|
|
@@ -100,6 +120,11 @@ export function buildComponent<TProps extends object>(
|
|
|
100
120
|
}
|
|
101
121
|
}
|
|
102
122
|
|
|
123
|
+
// Collect explicit entity prop names (stable list for hooks)
|
|
124
|
+
const explicitEntityPropNames = [...entityConfigs.entries()]
|
|
125
|
+
.filter(([_, c]) => c.selector)
|
|
126
|
+
.map(([name]) => name)
|
|
127
|
+
|
|
103
128
|
// 2. Implicit entities - collect lazily to avoid TDZ errors
|
|
104
129
|
const implicitConfigs = [...entityConfigs.entries()].filter(([_, c]) => !c.selector)
|
|
105
130
|
let implicitCollected = false
|
|
@@ -118,14 +143,20 @@ export function buildComponent<TProps extends object>(
|
|
|
118
143
|
// 3. Create React component
|
|
119
144
|
function ComponentImpl(props: TProps): ReactNode {
|
|
120
145
|
ensureImplicitCollected()
|
|
146
|
+
|
|
147
|
+
// Convert explicit entity refs to accessors (stable hook count — explicitEntityPropNames is fixed)
|
|
148
|
+
const renderProps = explicitEntityPropNames.length > 0
|
|
149
|
+
? useRenderProps(props, explicitEntityPropNames)
|
|
150
|
+
: props
|
|
151
|
+
|
|
121
152
|
// Evaluate condition at runtime
|
|
122
153
|
if (conditionFn) {
|
|
123
|
-
const condition = conditionFn(
|
|
154
|
+
const condition = conditionFn(renderProps)
|
|
124
155
|
if (!evaluateCondition(condition)) {
|
|
125
156
|
return null
|
|
126
157
|
}
|
|
127
158
|
}
|
|
128
|
-
return renderFn(
|
|
159
|
+
return renderFn(renderProps)
|
|
129
160
|
}
|
|
130
161
|
|
|
131
162
|
const MemoizedComponent = memo(ComponentImpl)
|
|
@@ -372,8 +403,19 @@ function createGetSelection(
|
|
|
372
403
|
for (const [propName, meta] of selectionsMap) {
|
|
373
404
|
const propValue = props[propName]
|
|
374
405
|
|
|
406
|
+
if (!propValue || typeof propValue !== 'object') {
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Case 0: Collection phase — prop has SCOPE_REF, merge directly into scope
|
|
411
|
+
if (SCOPE_REF in propValue) {
|
|
412
|
+
const targetScope = (propValue as { [SCOPE_REF]: SelectionScope })[SCOPE_REF]
|
|
413
|
+
targetScope.mergeFromSelectionMeta(meta.selection)
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
|
|
375
417
|
// Case 1: Prop is a field reference (from relation)
|
|
376
|
-
if (
|
|
418
|
+
if (FIELD_REF_META in propValue) {
|
|
377
419
|
const refMeta = (propValue as { [FIELD_REF_META]: { path: string[]; fieldName: string } })[FIELD_REF_META]
|
|
378
420
|
|
|
379
421
|
for (const [_key, field] of meta.selection.fields) {
|
|
@@ -386,7 +428,7 @@ function createGetSelection(
|
|
|
386
428
|
}
|
|
387
429
|
}
|
|
388
430
|
// Case 2: Prop is an EntityRef from root level
|
|
389
|
-
else if (
|
|
431
|
+
else if ('id' in propValue && 'fields' in propValue) {
|
|
390
432
|
for (const [_key, field] of meta.selection.fields) {
|
|
391
433
|
if (field.path.length === 1) {
|
|
392
434
|
fields.push({ ...field })
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { cloneElement, isValidElement, memo, type ReactElement, type ReactNode } from 'react'
|
|
2
|
+
import type { FieldAccessor, FieldRef, SelectionFieldMeta, SelectionMeta, AnyBrand } from '../types.js'
|
|
3
|
+
import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js'
|
|
4
|
+
import type { SelectionProvider } from '../types.js'
|
|
5
|
+
import { useField } from '../../hooks/useField.js'
|
|
6
|
+
import { isDevAnnotationsEnabled } from '../devAnnotations.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Props for Attribute component.
|
|
10
|
+
*/
|
|
11
|
+
export interface AttributeProps<T> {
|
|
12
|
+
/** The field to subscribe to */
|
|
13
|
+
field: FieldRef<T>
|
|
14
|
+
/** Maps the field accessor to props that get spread onto the child element */
|
|
15
|
+
format: (accessor: FieldAccessor<T>) => Record<string, unknown>
|
|
16
|
+
/** Single child element — receives the formatted props via cloneElement */
|
|
17
|
+
children: ReactElement
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Attribute component — applies field-derived props to a child element.
|
|
22
|
+
*
|
|
23
|
+
* Like `<Field>` but for attributes (style, className, data-*) instead of text content.
|
|
24
|
+
* Subscribes to the field, calls `format` to produce props, and clones the child with merged props.
|
|
25
|
+
* During selection collection, both the field and children's fields are tracked.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```tsx
|
|
29
|
+
* <Attribute field={tag.color} format={color => ({ style: { backgroundColor: color.value ?? '#666' } })}>
|
|
30
|
+
* <span>
|
|
31
|
+
* <Field field={tag.name} />
|
|
32
|
+
* </span>
|
|
33
|
+
* </Attribute>
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
function AttributeImpl<T>({ field, format, children }: AttributeProps<T>): ReactElement | null {
|
|
37
|
+
if (field === undefined || field === null) {
|
|
38
|
+
return children
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const accessor = useField(field)
|
|
42
|
+
const extraProps = {
|
|
43
|
+
...format(accessor),
|
|
44
|
+
...(isDevAnnotationsEnabled() ? { 'data-field': field[FIELD_REF_META]?.fieldName } : {}),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!isValidElement(children)) {
|
|
48
|
+
return children
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return cloneElement(children, extraProps)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const Attribute = memo(AttributeImpl) as typeof AttributeImpl
|
|
55
|
+
|
|
56
|
+
// Static method for selection extraction
|
|
57
|
+
const attributeWithSelection = Attribute as typeof Attribute & SelectionProvider & { [BINDX_COMPONENT]: true }
|
|
58
|
+
|
|
59
|
+
attributeWithSelection.getSelection = (
|
|
60
|
+
props: AttributeProps<unknown>,
|
|
61
|
+
collectNested: (children: ReactNode) => SelectionMeta,
|
|
62
|
+
): SelectionFieldMeta | null => {
|
|
63
|
+
if (props.field === undefined || props.field === null) {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const meta = props.field[FIELD_REF_META]
|
|
68
|
+
if (!meta) {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// During collection phase, skip — field access is already tracked by the collector proxy
|
|
73
|
+
if (meta.entityId === '') {
|
|
74
|
+
// But still analyze children for nested field selections
|
|
75
|
+
collectNested(props.children)
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
fieldName: meta.fieldName,
|
|
81
|
+
alias: meta.fieldName,
|
|
82
|
+
path: meta.path,
|
|
83
|
+
isArray: false,
|
|
84
|
+
isRelation: false,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
attributeWithSelection[BINDX_COMPONENT] = true
|
|
89
|
+
|
|
90
|
+
export { attributeWithSelection as AttributeWithMeta }
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import React, { memo, useCallback, useEffect, useMemo, useRef, useSyncExternalStore, type ReactElement } from 'react'
|
|
2
2
|
import { useBindxContext, useSchemaRegistry } from '../../hooks/BackendAdapterContext.js'
|
|
3
|
+
import { annotateElement } from '../devAnnotations.js'
|
|
3
4
|
import { useEntity } from '../../hooks/useEntity.js'
|
|
4
5
|
import { useSelectionCollection } from '../../hooks/useSelectionCollection.js'
|
|
5
|
-
import type { EntityAccessor, SelectionMeta } from '../types.js'
|
|
6
|
+
import type { EntityAccessor, EntityRef, SelectionMeta } from '../types.js'
|
|
6
7
|
import { type EntityDef, type EntityUniqueWhere, type AnyBrand, type FieldError, EntityHandle, type SnapshotStore, type ActionDispatcher, type SchemaRegistry, type CommonEntity } from '@contember/bindx'
|
|
7
8
|
|
|
8
9
|
// ==================== Props Types ====================
|
|
@@ -15,7 +16,7 @@ interface EntityBaseProps<TRoleMap extends Record<string, object>> {
|
|
|
15
16
|
/** Entity definition reference */
|
|
16
17
|
entity: EntityDef<TRoleMap>
|
|
17
18
|
/** Render function receiving typed entity accessor with direct field access */
|
|
18
|
-
children: (entity:
|
|
19
|
+
children: (entity: EntityRef<CommonEntity<TRoleMap>>) => React.ReactNode
|
|
19
20
|
/** Error fallback */
|
|
20
21
|
error?: (error: FieldError) => React.ReactNode
|
|
21
22
|
}
|
|
@@ -284,7 +285,8 @@ function EntityHandleRenderer({
|
|
|
284
285
|
|
|
285
286
|
useEffect(() => () => { rawHandle.dispose() }, [rawHandle])
|
|
286
287
|
|
|
287
|
-
|
|
288
|
+
const result = children(handle as EntityAccessor<unknown>)
|
|
289
|
+
return <>{annotateElement(result, { 'data-entity': entityType, 'data-entity-id': entityId })}</>
|
|
288
290
|
}
|
|
289
291
|
|
|
290
292
|
// ==================== Main Entity Component ====================
|
|
@@ -3,6 +3,7 @@ import { type EntityDef, type EntityWhere, type EntityOrderBy, type FieldError,
|
|
|
3
3
|
import { useEntityList } from '../../hooks/useEntityList.js'
|
|
4
4
|
import { useSelectionCollection } from '../../hooks/useSelectionCollection.js'
|
|
5
5
|
import type { EntityAccessor } from '../types.js'
|
|
6
|
+
import { annotateElement } from '../devAnnotations.js'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Props for EntityList component
|
|
@@ -104,9 +105,10 @@ function EntityListComponent<TRoleMap extends Record<string, object>>({
|
|
|
104
105
|
|
|
105
106
|
// Phase 3: Runtime render — items are already EntityAccessors from the hook
|
|
106
107
|
const items = result.items.map((item: (typeof result.items)[number], index: number) => {
|
|
108
|
+
const result = children(item as unknown as EntityAccessor<CommonEntity<TRoleMap>>, index)
|
|
107
109
|
return (
|
|
108
110
|
<React.Fragment key={item.id}>
|
|
109
|
-
{
|
|
111
|
+
{annotateElement(result, { 'data-entity': entityType, 'data-entity-id': item.id })}
|
|
110
112
|
</React.Fragment>
|
|
111
113
|
)
|
|
112
114
|
})
|
|
@@ -2,6 +2,7 @@ import { memo, type ReactElement } from 'react'
|
|
|
2
2
|
import type { FieldProps, SelectionFieldMeta, SelectionProvider } from '../types.js'
|
|
3
3
|
import { FIELD_REF_META, BINDX_COMPONENT } from '../types.js'
|
|
4
4
|
import { useField } from '../../hooks/useField.js'
|
|
5
|
+
import { annotateElement, isDevAnnotationsEnabled } from '../devAnnotations.js'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Field component - renders a scalar field value
|
|
@@ -28,13 +29,18 @@ function FieldImpl<T>({ field, children, format }: FieldProps<T>): ReactElement
|
|
|
28
29
|
|
|
29
30
|
// useField() subscribes to store and returns FieldAccessor with .value access
|
|
30
31
|
const accessor = useField(field)
|
|
32
|
+
const fieldName = field[FIELD_REF_META]?.fieldName
|
|
31
33
|
|
|
32
34
|
if (children) {
|
|
33
|
-
|
|
35
|
+
const result = children(accessor)
|
|
36
|
+
return <>{annotateElement(result, { 'data-field': fieldName })}</>
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
if (format) {
|
|
37
|
-
|
|
40
|
+
const content = format(accessor.value)
|
|
41
|
+
return isDevAnnotationsEnabled() && fieldName
|
|
42
|
+
? <span data-field={fieldName}>{content}</span>
|
|
43
|
+
: <>{content}</>
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
// Default: render value as string
|
|
@@ -42,7 +48,9 @@ function FieldImpl<T>({ field, children, format }: FieldProps<T>): ReactElement
|
|
|
42
48
|
return null
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
return
|
|
51
|
+
return isDevAnnotationsEnabled() && fieldName
|
|
52
|
+
? <span data-field={fieldName}>{String(accessor.value)}</span>
|
|
53
|
+
: <>{String(accessor.value)}</>
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export const Field = memo(FieldImpl) as typeof FieldImpl
|
|
@@ -4,6 +4,7 @@ import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from '../types.js'
|
|
|
4
4
|
import { mergeSelections } from '../SelectionMeta.js'
|
|
5
5
|
import { SelectionScope, generateHasManyAlias, type HasManyAccessor } from '@contember/bindx'
|
|
6
6
|
import { useHasMany } from '../../hooks/useHasMany.js'
|
|
7
|
+
import { annotateElement } from '../devAnnotations.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* HasMany component - renders a has-many relation
|
|
@@ -38,8 +39,16 @@ function HasManyImpl<
|
|
|
38
39
|
>({ field, children }: HasManyProps<TEntity, TSelected, TBrand, TEntityName, TSchema>): ReactElement {
|
|
39
40
|
// useHasMany() subscribes to store and returns HasManyAccessor with .map()/.items/.length
|
|
40
41
|
const accessor = useHasMany(field)
|
|
42
|
+
const targetType = field[FIELD_REF_META]?.targetType
|
|
43
|
+
|
|
41
44
|
const items = accessor.map((item, index) => {
|
|
42
|
-
|
|
45
|
+
const result = children(item, index)
|
|
46
|
+
return <React.Fragment key={item.id}>{
|
|
47
|
+
annotateElement(result, {
|
|
48
|
+
'data-entity': targetType ?? '',
|
|
49
|
+
'data-entity-id': item.id,
|
|
50
|
+
})
|
|
51
|
+
}</React.Fragment>
|
|
43
52
|
})
|
|
44
53
|
|
|
45
54
|
return <>{items}</>
|
|
@@ -4,6 +4,7 @@ import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from '../types.js'
|
|
|
4
4
|
import { mergeSelections } from '../SelectionMeta.js'
|
|
5
5
|
import { SelectionScope, type HasOneAccessor } from '@contember/bindx'
|
|
6
6
|
import { useHasOne } from '../../hooks/useHasOne.js'
|
|
7
|
+
import { annotateElement } from '../devAnnotations.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* HasOne component - renders a has-one relation
|
|
@@ -29,8 +30,11 @@ function HasOneImpl<
|
|
|
29
30
|
>({ field, children }: HasOneProps<TEntity, TSelected, TBrand, TEntityName, TSchema>): ReactElement {
|
|
30
31
|
// useHasOne() subscribes to store and returns HasOneAccessor with .$entity
|
|
31
32
|
const accessor = useHasOne(field)
|
|
33
|
+
const relationName = field[FIELD_REF_META]?.fieldName
|
|
34
|
+
|
|
32
35
|
// Get the related entity reference (always available, may be placeholder with id=null)
|
|
33
|
-
|
|
36
|
+
const result = children(accessor.$entity)
|
|
37
|
+
return <>{annotateElement(result, { 'data-relation': relationName ?? '' })}</>
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
export const HasOne = memo(HasOneImpl) as typeof HasOneImpl
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { cloneElement, isValidElement, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Whether dev-mode data attributes are enabled.
|
|
5
|
+
*
|
|
6
|
+
* Controlled via `BINDX_DEV_ANNOTATIONS` env variable.
|
|
7
|
+
* Bundlers (Vite, webpack) replace `import.meta.env` at build time,
|
|
8
|
+
* so this entire module tree-shakes away in production builds.
|
|
9
|
+
*/
|
|
10
|
+
// @ts-ignore -- import.meta.env is provided by bundler
|
|
11
|
+
const DEV_ANNOTATIONS: boolean = typeof import.meta !== 'undefined' && import.meta.env?.VITE_BINDX_DEV_ANNOTATIONS === 'true'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* In dev mode, injects data attributes onto an existing React element via cloneElement.
|
|
15
|
+
* If annotations are disabled or the node is not a valid element, returns as-is.
|
|
16
|
+
*/
|
|
17
|
+
export function annotateElement(
|
|
18
|
+
element: ReactNode,
|
|
19
|
+
attrs: Record<string, string>,
|
|
20
|
+
): ReactNode {
|
|
21
|
+
if (!DEV_ANNOTATIONS || !isValidElement(element)) {
|
|
22
|
+
return element
|
|
23
|
+
}
|
|
24
|
+
return cloneElement(element, attrs)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns true if dev annotations are enabled.
|
|
29
|
+
* Use for conditional wrapping (e.g., Field text render in span).
|
|
30
|
+
*/
|
|
31
|
+
export function isDevAnnotationsEnabled(): boolean {
|
|
32
|
+
return DEV_ANNOTATIONS
|
|
33
|
+
}
|
package/src/jsx/index.ts
CHANGED
|
@@ -52,6 +52,7 @@ export {
|
|
|
52
52
|
} from './analyzer.js'
|
|
53
53
|
|
|
54
54
|
// Components
|
|
55
|
+
export { Attribute, AttributeWithMeta, type AttributeProps } from './components/Attribute.js'
|
|
55
56
|
export { Field, FieldWithMeta } from './components/Field.js'
|
|
56
57
|
export { HasMany, HasManyWithMeta } from './components/HasMany.js'
|
|
57
58
|
export { HasOne, HasOneWithMeta } from './components/HasOne.js'
|