Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/bindx-react/src/jsx/componentBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export class ComponentBuilderImpl<
private readonly roles: readonly string[],
private readonly hasInterfacesMode: boolean = false,
private readonly conditionFn: ((props: Record<string, unknown>) => Condition) | null = null,
private readonly slotNames: readonly string[] = ['children'],
) {}

entity(
Expand All @@ -65,6 +66,7 @@ export class ComponentBuilderImpl<
this.roles,
this.hasInterfacesMode,
this.conditionFn,
this.slotNames,
)
}

Expand Down Expand Up @@ -93,6 +95,7 @@ export class ComponentBuilderImpl<
this.roles,
true, // Enable interfaces mode for discovery of implicit interface props
this.conditionFn,
this.slotNames,
)
}

Expand All @@ -104,6 +107,7 @@ export class ComponentBuilderImpl<
this.roles,
this.hasInterfacesMode,
this.conditionFn,
this.slotNames,
)
}

Expand All @@ -114,6 +118,18 @@ export class ComponentBuilderImpl<
this.roles,
this.hasInterfacesMode,
conditionFn,
this.slotNames,
)
}

slots(names: readonly string[]): ComponentBuilderImpl<TState> {
return new ComponentBuilderImpl(
this.schemaRegistry,
this.entityConfigs,
this.roles,
this.hasInterfacesMode,
this.conditionFn,
names,
)
}

Expand All @@ -125,6 +141,7 @@ export class ComponentBuilderImpl<
this.hasInterfacesMode,
this.schemaRegistry,
this.conditionFn,
this.slotNames,
)
}
}
Expand Down
26 changes: 26 additions & 0 deletions packages/bindx-react/src/jsx/componentBuilder.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,32 @@ export interface ComponentBuilder<
*/
if(conditionFn: (props: BuildEntityProps<TState['__entityProps'], TState['__roles']>) => Condition): ComponentBuilder<TState>

/**
* Declare which ReactNode-shaped props should be walked by selection analysis.
* Components inside these slots contribute their selections to the fetch plan.
*
* Default: `['children']`. Call with `[]` to opt out.
*
* @param names - Prop names to analyze. Must be keys of the props declared via `.props<>()`.
*
* @example
* ```typescript
* createComponent()
* .entity('entity', schema.Article)
* .props<{ header: ReactNode; children: ReactNode }>()
* .slots(['children', 'header'])
* .render(({ entity, header, children }) => (
* <article>
* <header>{header}</header>
* {children}
* </article>
* ))
* ```
*/
slots<TNames extends readonly (keyof TState['__scalarProps'] & string)[]>(
names: TNames,
): ComponentBuilder<TState>

/**
* Build the component with the render function.
*
Expand Down
19 changes: 17 additions & 2 deletions packages/bindx-react/src/jsx/componentFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export function buildComponent<TProps extends object>(
hasInterfacesMode: boolean,
schemaRegistry: SchemaRegistry<Record<string, object>> | null,
conditionFn: ((props: TProps) => Condition) | null,
slotNames: readonly string[],
): unknown {
const selectionsMap = new Map<string, SelectionPropMeta>()

Expand Down Expand Up @@ -178,7 +179,7 @@ export function buildComponent<TProps extends object>(
collectNested,
): SelectionFieldMeta | SelectionFieldMeta[] | null => {
ensureImplicitCollected()
return createGetSelection(selectionsMap)(props, collectNested)
return createGetSelection(selectionsMap, slotNames)(props, collectNested)
}

// 6. Attach fragment properties ($propName) for explicit entities
Expand Down Expand Up @@ -393,10 +394,11 @@ function createFragment(
*/
function createGetSelection(
selectionsMap: Map<string, SelectionPropMeta>,
slotNames: readonly string[],
): SelectionProvider['getSelection'] {
return (
props: Record<string, unknown>,
_collectNested,
collectNested,
): SelectionFieldMeta[] | null => {
const fields: SelectionFieldMeta[] = []

Expand Down Expand Up @@ -437,6 +439,19 @@ function createGetSelection(
}
}

// Walk configured slot props so nested bindx components inside children
// (or any other slot) contribute their selection to the fetch plan.
for (const slotName of slotNames) {
const slotValue = props[slotName]
if (slotValue === undefined || slotValue === null) {
continue
}
const nested = collectNested(slotValue as ReactNode)
for (const field of nested.fields.values()) {
fields.push({ ...field })
}
}

return fields.length > 0 ? fields : null
}
}
121 changes: 120 additions & 1 deletion tests/selectionCollection.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import './setup'
import { describe, test, expect } from 'bun:test'
import React from 'react'
import React, { type ReactNode } from 'react'
import {
createComponent,
entityDef,
Expand Down Expand Up @@ -562,4 +562,123 @@ describe('Selection Collection with Schema', () => {
expect(ffsField!.isRelation).toBe(true)
expect(ffsField!.nested!.fields.has('name')).toBe(true)
})

test('createComponent children via `children` prop contribute to selection', () => {
const { SelectionScope } = require('@contember/bindx')
const { createCollectorProxy, collectSelection, mergeSelections } = require('@contember/bindx-react')

const VoucherLabel = createComponent()
.entity('entity', schema.Voucher, e => e.label())
.render(({ entity }: any) => <span>{entity.label.value}</span>)
;(VoucherLabel as any).$entity

const VoucherShell = createComponent()
.entity('entity', schema.Voucher, e => e.code())
.props<{ children: ReactNode }>()
.render(({ entity, children }: any) => (
<section>
<span>{entity.code.value}</span>
{children}
</section>
))
;(VoucherShell as any).$entity

const registry = new SchemaRegistry(schemaDef)
const scope = new SelectionScope()
const collector = createCollectorProxy(scope, 'Voucher', registry)

const jsx = (
<VoucherShell entity={collector}>
<VoucherLabel entity={collector} />
</VoucherShell>
)

const jsxSel = collectSelection(jsx)
const selection = scope.toSelectionMeta()
mergeSelections(selection, jsxSel)

expect(selection.fields.has('code')).toBe(true)
expect(selection.fields.has('label')).toBe(true)
})

test('createComponent custom slot contributes to selection when listed via .slots()', () => {
const { SelectionScope } = require('@contember/bindx')
const { createCollectorProxy, collectSelection, mergeSelections } = require('@contember/bindx-react')

const VoucherCode = createComponent()
.entity('entity', schema.Voucher, e => e.code())
.render(({ entity }: any) => <span>{entity.code.value}</span>)
;(VoucherCode as any).$entity

const VoucherShell = createComponent()
.entity('entity', schema.Voucher, e => e.label())
.props<{ header: ReactNode; footer: ReactNode }>()
.slots(['header', 'footer'])
.render(({ entity, header, footer }: any) => (
<section>
<header>{header}</header>
<span>{entity.label.value}</span>
<footer>{footer}</footer>
</section>
))
;(VoucherShell as any).$entity

const registry = new SchemaRegistry(schemaDef)
const scope = new SelectionScope()
const collector = createCollectorProxy(scope, 'Voucher', registry)

const jsx = (
<VoucherShell
entity={collector}
header={<VoucherCode entity={collector} />}
footer={null}
/>
)

const jsxSel = collectSelection(jsx)
const selection = scope.toSelectionMeta()
mergeSelections(selection, jsxSel)

expect(selection.fields.has('label')).toBe(true)
expect(selection.fields.has('code')).toBe(true)
})

test('createComponent opts out of slot analysis via .slots([])', () => {
const { SelectionScope } = require('@contember/bindx')
const { createCollectorProxy, collectSelection, mergeSelections } = require('@contember/bindx-react')

const VoucherLabel = createComponent()
.entity('entity', schema.Voucher, e => e.label())
.render(({ entity }: any) => <span>{entity.label.value}</span>)
;(VoucherLabel as any).$entity

const OpaqueShell = createComponent()
.entity('entity', schema.Voucher, e => e.code())
.props<{ children: ReactNode }>()
.slots([])
.render(({ entity, children }: any) => (
<section>
<span>{entity.code.value}</span>
{children}
</section>
))
;(OpaqueShell as any).$entity

const registry = new SchemaRegistry(schemaDef)
const scope = new SelectionScope()
const collector = createCollectorProxy(scope, 'Voucher', registry)

const jsx = (
<OpaqueShell entity={collector}>
<VoucherLabel entity={collector} />
</OpaqueShell>
)

const jsxSel = collectSelection(jsx)
const selection = scope.toSelectionMeta()
mergeSelections(selection, jsxSel)

expect(selection.fields.has('code')).toBe(true)
expect(selection.fields.has('label')).toBe(false)
})
})