Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
608dba6
refactor(bindx-ui): rewrite internal imports to use #bindx-ui/ prefix
matej21 Mar 26, 2026
962c81e
feat(bindx-ui): add Vite plugin, CLI tool, and defaults context for c…
matej21 Mar 26, 2026
eb4d994
test(bindx-ui): add tests for Vite plugin, CLI eject/restore, registr…
matej21 Mar 26, 2026
0604700
fix(bindx-ui): resolve vite plugin fallback directly to source files
matej21 Mar 26, 2026
9f13d7f
test(bindx-ui): add e2e test for component override via Vite plugin
matej21 Mar 26, 2026
3165778
refactor(bindx-ui): restructure to one component per file for granula…
matej21 Mar 26, 2026
1359f8b
feat(bindx-ui): add backport system with three-way merge and agent pr…
matej21 Mar 27, 2026
dd19da1
feat(bindx-ui): add --agent --all batch mode and --skip command
matej21 Mar 27, 2026
bb711e9
fix(test): wait for datagrid-table element instead of container in br…
matej21 Mar 27, 2026
bb60539
fix(bindx-ui): fix command injection, conflict metadata, and extract …
matej21 Mar 27, 2026
56c24b7
refactor(bindx-repeater): delegate selection collection from UI to co…
matej21 Mar 27, 2026
9149dd1
test(browser): add block repeater browser tests
matej21 Mar 27, 2026
cc58ba5
fix(test): increase timeouts and add waitFor guards in block repeater…
matej21 Mar 27, 2026
1d4739a
fix(test): wait for data load and disabled state in block repeater br…
matej21 Mar 27, 2026
977f582
fix(test): increase test-level timeouts for slow CI in block repeater…
matej21 Mar 27, 2026
e7ac874
fix(test): make block repeater browser tests work with seeded data on CI
matej21 Mar 27, 2026
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
44 changes: 20 additions & 24 deletions packages/bindx-react/src/jsx/withCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,39 +7,35 @@ import type { ReactNode } from 'react'
* analyzing runtime children. Props contain collector proxies, so field accesses
* are tracked automatically. The returned JSX is analyzed for nested components.
*
* When called with a single argument, the component function itself is used as
* staticRender. This works when the component is pure JSX (no hooks) and delegates
* to an inner component that has its own `getSelection`.
*
* @example
* ```tsx
* // Simple: children is a render prop receiving has-one entity
* export const SelectField = withCollector(
* function SelectField({ field, children, ... }) { ... },
* (props) => props.children(props.field.$entity)
* )
*
* // Composing with HasMany for iteration
* export const DefaultRepeater = withCollector(
* function DefaultRepeater({ field, children, ... }) { ... },
* (props) => (
* <HasMany field={props.field}>
* {item => props.children(item, collectionItemInfo)}
* </HasMany>
* )
* // Single-arg: component IS the staticRender (no hooks, delegates to inner)
* export const StyledRepeater = withCollector(
* function StyledRepeater({ field, children }) {
* return (
* <RepeaterCore field={field}>
* {(items) => <div>{items.map((e, info) => children(e, info))}</div>}
* </RepeaterCore>
* )
* }
* )
*
* // Programmatic field access (no JSX needed)
* export const Uploader = withCollector(
* function Uploader({ field, fileType }) { ... },
* (props) => {
* const entity = props.field.$entity
* for (const ext of props.fileType.extractors) entity[ext.fieldName]
* return null
* }
* // Two-arg: explicit staticRender for components with hooks or custom collection
* export const SelectField = withCollector(
* function SelectField({ field, children }) { ... },
* (props) => props.children(props.field.$entity)
* )
* ```
*/
export function withCollector<TComponent extends (...args: never[]) => ReactNode>(
component: TComponent,
staticRender: (props: Parameters<TComponent>[0]) => ReactNode,
staticRender?: (props: Parameters<TComponent>[0]) => ReactNode,
): TComponent {
(component as TComponent & { staticRender: typeof staticRender }).staticRender = staticRender
const render = staticRender ?? component as unknown as (props: Parameters<TComponent>[0]) => ReactNode
;(component as TComponent & { staticRender: typeof render }).staticRender = render
return component
}
127 changes: 95 additions & 32 deletions packages/bindx-repeater/src/components/BlockRepeater.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function BlockRepeater<
sortableBy,
blocks,
children,
}: BlockRepeaterProps<TEntity, TSelected, TBrand, TEntityName, TSchema, TBlockNames>): ReactElement {
}: BlockRepeaterProps<TEntity, TSelected, TBrand, TEntityName, TSchema, TBlockNames>): ReactElement | null {
const fieldAccessor = useHasMany(field)
const sortedItems = useSortedItems(fieldAccessor, sortableBy)

Expand Down Expand Up @@ -198,6 +198,9 @@ export function BlockRepeater<
}
}, [field, sortableBy, blocks, discriminationField])

if (!children) {
return null
}
return <>{children(items, methods)}</>
}

Expand All @@ -219,44 +222,88 @@ function createBlockRepeaterWithSelection() {
: null

const scope = new SelectionScope()
const collectorEntity = createCollectorProxy<unknown>(scope)

const mockItems: BlockRepeaterItems<unknown> = {
map: (fn) => {
fn(collectorEntity, {
index: 0,
isFirst: true,
isLast: true,
remove: () => {},
moveUp: () => {},
moveDown: () => {},
blockType: null,
block: undefined,
})
return []
},
length: 0,
}
const collectorEntity = createCollectorProxy<object>(scope)

const blockNames = Object.keys(props.blocks) as string[]
const blocksRecord = props.blocks as Record<string, BlockDefinition>

// Path 1: collect field deps from block renderers
// Calls staticRender if present, or any other callable properties (render, form)
// that accept (entity, info) and return ReactNode.
for (const blockName of blockNames) {
const block = blocksRecord[blockName]
if (!block) continue

const mockInfo: BlockRepeaterItemInfo = {
index: 0,
isFirst: true,
isLast: true,
remove: () => {},
moveUp: () => {},
moveDown: () => {},
blockType: blockName,
block: { name: blockName, label: block.label },
}

const renderers = block.staticRender
? [block.staticRender]
: collectBlockRenderers(block)

const mockMethods: BlockRepeaterMethods<string> = {
addItem: () => {},
isEmpty: true,
blockList: [],
for (const renderer of renderers) {
const jsx = renderer(collectorEntity, mockInfo)
if (jsx) {
collectNested(jsx)
}
}
}

const syntheticChildren = props.children(mockItems, mockMethods)
// Path 2: children callback — for headless use or when blocks lack staticRender
let jsxSelection: SelectionMeta | undefined
if (props.children) {
const mockItems: BlockRepeaterItems<unknown> = {
map: (fn) => {
for (const blockName of blockNames) {
fn(collectorEntity, {
index: 0,
isFirst: true,
isLast: true,
remove: () => {},
moveUp: () => {},
moveDown: () => {},
blockType: blockName,
block: { name: blockName, label: blocksRecord[blockName]?.label },
})
}
// Also call with null blockType for any fallback/default rendering paths
fn(collectorEntity, {
index: 0,
isFirst: true,
isLast: true,
remove: () => {},
moveUp: () => {},
moveDown: () => {},
blockType: null,
block: undefined,
})
return []
},
length: 0,
}

const mockMethods: BlockRepeaterMethods<string> = {
addItem: () => {},
isEmpty: true,
blockList: [],
}

// Call block render/form functions so the collector proxy records field accesses
const blockJsx: ReactNode[] = []
for (const blockDef of Object.values(props.blocks) as BlockDefinition[]) {
if (blockDef.render) blockJsx.push(blockDef.render(collectorEntity as EntityAccessor<object>))
if (blockDef.form) blockJsx.push(blockDef.form(collectorEntity as EntityAccessor<object>))
const syntheticChildren = props.children(mockItems, mockMethods)
jsxSelection = collectNested(syntheticChildren)
}

const jsxSelection = collectNested([syntheticChildren, ...blockJsx])

const nestedSelection = scope.toSelectionMeta()
mergeSelections(nestedSelection, jsxSelection)
if (jsxSelection) {
mergeSelections(nestedSelection, jsxSelection)
}

// Add discrimination field to selection
nestedSelection.fields.set(props.discriminationField, {
Expand Down Expand Up @@ -301,3 +348,19 @@ function createBlockRepeaterWithSelection() {
}

export const BlockRepeaterWithMeta = createBlockRepeaterWithSelection()

type BlockRenderer = (entity: EntityAccessor<object>, info: BlockRepeaterItemInfo) => ReactNode

/**
* Discovers callable renderer functions on a block definition (e.g., render, form).
* Used during selection collection to call all renderers with a collector proxy.
*/
function collectBlockRenderers(block: BlockDefinition): BlockRenderer[] {
const renderers: BlockRenderer[] = []
for (const value of Object.values(block)) {
if (typeof value === 'function') {
renderers.push(value as BlockRenderer)
}
}
return renderers
}
4 changes: 2 additions & 2 deletions packages/bindx-repeater/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export { Repeater, RepeaterWithMeta } from './Repeater.js'
export { BlockRepeater, BlockRepeaterWithMeta } from './BlockRepeater.js'
export { Repeater } from './Repeater.js'
export { BlockRepeater } from './BlockRepeater.js'
4 changes: 2 additions & 2 deletions packages/bindx-repeater/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ export {
} from './utils/index.js'

// Components
export { Repeater, RepeaterWithMeta } from './components/index.js'
export { BlockRepeater, BlockRepeaterWithMeta } from './components/index.js'
export { Repeater } from './components/index.js'
export { BlockRepeater } from './components/index.js'
15 changes: 10 additions & 5 deletions packages/bindx-repeater/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,15 @@ export interface RepeaterProps<
*/
export interface BlockDefinition<TEntity extends object = object, TSelected = TEntity> {
label?: ReactNode
/** Render function for block preview. Used for selection collection. */
render?: (entity: EntityAccessor<TEntity, TSelected>) => ReactNode
/** Form function for block editing. Used for selection collection. */
form?: (entity: EntityAccessor<TEntity, TSelected>) => ReactNode
/**
* Called during selection collection to discover field dependencies for this block type.
* Receives a collector proxy entity and block info.
* Return JSX that accesses all fields this block type needs.
*
* When present, core getSelection calls this directly per block type
* instead of relying on the children callback for field discovery.
*/
staticRender?: (entity: EntityAccessor<object>, info: BlockRepeaterItemInfo) => ReactNode
}

/**
Expand Down Expand Up @@ -223,5 +228,5 @@ export interface BlockRepeaterProps<
blocks: Record<TBlockNames, BlockDefinition>

/** Render function that receives items collection and methods */
children: BlockRepeaterRenderFn<TEntity, TSelected, TBrand, TEntityName, TSchema, TBlockNames>
children?: BlockRepeaterRenderFn<TEntity, TSelected, TBrand, TEntityName, TSchema, TBlockNames>
}
21 changes: 18 additions & 3 deletions packages/bindx-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
"version": "0.1.23",
"type": "module",
"exports": {
".": "./src/index.ts"
".": "./src/index.ts",
"./vite-plugin": "./src/vite-plugin.ts",
"./utils": "./src/utils/index.ts",
"./_internal/*": "./src/*"
},
"scripts": {
"build": "vite build",
Expand Down Expand Up @@ -31,7 +34,13 @@
"tailwind-merge": "^3.3.1"
},
"peerDependencies": {
"react": ">=18.0.0"
"react": ">=18.0.0",
"vite": ">=5.0.0"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
},
"devDependencies": {
"@types/react": "^19.1.8",
Expand All @@ -43,5 +52,11 @@
"url": "https://github.com/contember/bindx.git",
"directory": "packages/bindx-ui"
},
"license": "MIT"
"license": "MIT",
"bin": {
"bindx-ui": "./dist/cli/cli.js"
},
"imports": {
"#bindx-ui/*": "./src/*"
}
}
Loading