Hello,
I am running into a bit of an issue when trying to make a “composable” filter section in Builder.
What I have is a React component that you can pass a list of sections with their respective items, specifying the “type” of the section and the “type” of each individual item so that filtering can happen.
Now, the issue arises when trying to make a builder wrapper around this component, since the underlying component accepts any content as the section header and each individual item, because it has a very simple purpose, filter by type, the style is irrelevant.
The approach I took was composition, I created multiple components that semantically represent each part of the component tree, which in turn would map into sections with items, they don’t do anything, they are just there for grouping.
So my component tree looks like this:
Here is the code that implements all of this functionality:
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import {
Children,
isValidElement,
useEffect,
useState,
type FC,
type PropsWithChildren,
type ReactElement,
type ReactNode,
} from 'react'
import { BuilderComponent, type BuilderElement } from '@builder.io/react'
import {
type BuilderBlock,
type RegisteredComponent,
} from '@builder.io/sdk-react'
import {
FilterSection as FilterSectionReact,
type Item,
type SectionWithItems,
} from '@local/ui/FilterSection'
type FilterGroup<TFilterOptions extends string> = {
title: string
filterOptions: { name: TFilterOptions }[]
}
function transformFilterGroup<TFilterOptions extends string>(
filterGroup: FilterGroup<TFilterOptions>,
) {
return {
...filterGroup,
filterOptions: filterGroup.filterOptions.map(
filterOption => filterOption.name,
),
}
}
function isBuilderBlock(block: any): block is BuilderBlock {
return (
typeof block === 'object' &&
block !== null &&
block !== undefined &&
block['@type'] === '@builder.io/sdk:Element'
)
}
function builderBlockToReactElement(
block: BuilderBlock | BuilderBlock[],
options: { isChild: boolean },
) {
console.log(block)
return (
<BuilderComponent
content={{
data: {
blocks: Array.isArray(block)
? (block as BuilderElement[])
: ([block] as BuilderElement[]),
},
}}
isChild={options.isChild}
/>
)
}
function getGroupHeader(groupChildren: BuilderBlock[]): ReactElement | null {
const groupHeaderBlock = groupChildren.find(
block => block.component?.name === 'FilterSectionGroupHeader',
)
const effectiveHeader = groupHeaderBlock?.children
const groupHeaderElement =
groupHeaderBlock === undefined
? null
: builderBlockToReactElement(effectiveHeader, { isChild: true })
return groupHeaderElement
}
function getItemsForGroup<TItemTypes extends string>(
groupChildren: BuilderBlock[],
) {
const groupItems: Item<TItemTypes>[] = []
for (const groupItemBlock of groupChildren) {
const itemType = groupItemBlock.component?.options?.type as
| TItemTypes
| undefined
if (itemType === undefined) continue
const effectiveItem = groupItemBlock.children
groupItems.push({
type: itemType,
itemElement: builderBlockToReactElement(effectiveItem, {
isChild: true,
}),
})
}
return groupItems
}
function buildSectionsWithItems<
TSectionTypes extends string,
TItemTypes extends string,
>(filterSectionGroups: ReactNode[]) {
const groupWithItems: SectionWithItems<TSectionTypes, TItemTypes>[] = []
for (const group of filterSectionGroups) {
if (!isValidElement(group)) continue
const groupBuilderBlock = group.props.block
if (!isBuilderBlock(groupBuilderBlock)) continue
const groupType = groupBuilderBlock.component?.options?.type as
| TSectionTypes
| undefined
if (groupType === undefined) continue
const groupHeader = getGroupHeader(groupBuilderBlock.children ?? [])
const groupItems = getItemsForGroup<TItemTypes>(
groupBuilderBlock.children ?? [],
)
groupWithItems.push({
type: groupType,
sectionHeader: groupHeader,
items: groupItems,
})
}
return groupWithItems
}
type FilterSectionProps<
TSectionTypes extends string,
TItemTypes extends string,
> = PropsWithChildren<{
itemFilterGroup: FilterGroup<TItemTypes>
sectionFilterGroup: FilterGroup<TSectionTypes>
}>
export const FilterSection = <
TSectionTypes extends string,
TItemTypes extends string,
>({
itemFilterGroup,
sectionFilterGroup,
children,
}: FilterSectionProps<TSectionTypes, TItemTypes>) => {
const itemFilterGroupTransformed = transformFilterGroup(itemFilterGroup)
const sectionFilterGroupTransformed = transformFilterGroup(sectionFilterGroup)
const filterSectionGroups = Children.toArray(children)
const sectionsWithItems = buildSectionsWithItems<TSectionTypes, TItemTypes>(
filterSectionGroups,
)
// Somehow <BuilderComponent /> is causing a hydration error. I was not able to pinpoint the issue so we just have to skip pre-rendering on the server.
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
if (!isClient) return null
return (
<FilterSectionReact
itemFilterGroup={itemFilterGroupTransformed}
sectionFilterGroup={sectionFilterGroupTransformed}
sectionsWithItems={sectionsWithItems}
/>
)
}
const filterGroupInput = {
type: 'object',
required: true,
subFields: [
{
name: 'title',
type: 'string',
defaultValue: 'Title for filter group',
required: true,
},
{
name: 'filterOptions',
type: 'list',
required: true,
subFields: [
{
name: 'name',
type: 'string',
required: true,
},
],
defaultValue: [],
},
],
defaultValue: {
title: 'Some filter group',
filterOptions: [],
},
}
export const FilterSectionRegistration: RegisteredComponent = {
component: FilterSection,
name: 'Filter Section',
inputs: [
{
name: 'sectionFilterGroup',
...filterGroupInput,
},
{
name: 'itemFilterGroup',
...filterGroupInput,
},
],
canHaveChildren: true,
childRequirements: {
message:
'Children component must be a Filter Section Group. Use it to group Filter Section Items together to compose a filter section',
component: 'FilterSectionGroup',
},
}
const NoOp: FC<PropsWithChildren<{ type: string }>> = ({ children }) => children
export const FilterSectionGroupRegistration: RegisteredComponent = {
component: NoOp,
name: 'FilterSectionGroup',
inputs: [
{
name: 'type',
friendlyName: 'Group type',
type: 'string',
required: true,
defaultValue: 'Some type',
helperText: `Type of the section used for filtering. It MUST match one of the filter options found in the FilterSection component under the "Section filter group" input. Case sensitive`,
},
],
canHaveChildren: true,
childRequirements: {
message:
'Children component must be a Filter Section Item or Filter Section Group Header.',
query: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'component.name': {
$in: ['FilterSectionGroupItem', 'FilterSectionGroupHeader'],
},
},
},
}
export const FilterSectionGroupHeaderRegistration: RegisteredComponent = {
component: NoOp,
name: 'FilterSectionGroupHeader',
canHaveChildren: true,
}
export const FilterSectionGroupItemRegistration: RegisteredComponent = {
component: NoOp,
name: 'FilterSectionGroupItem',
inputs: [
{
name: 'type',
friendlyName: 'Item type',
type: 'string',
required: true,
defaultValue: 'Some type',
helperText: `Type of the item used for filtering. It MUST match one of the filter options found in the FilterSection component under the "Item filter group" input. Case sensitive`,
},
],
canHaveChildren: true,
}
What I’m trying to achieve is to extract the child of each “semantic” component and render it.
My specific issue is with the function “builderBlockToReactElement”, it seems like when passing the content prop to the element, if that content is NOT a DEFAULT builder block, it won’t render component, which I don’t understand why, since the representation of both custom and default components as blocks have the same object shape, so why can’t builder recreate the custom component, but it’s able to recreate it’s own as React components?
Yes, I tried ONLY putting the custom component as a child, and the issue persists, as long as it’s a custom component.
So if there’s a way to imperatively/programatically render a custom builder component or if what I’m trying to accomplish is impossible, please let me know, because I’ve searched through swathes of the internet to find nothing.
Builder content link
Builder public api key
5861e770ff6944ae968b0e0096a65be9
Code stack you are integrating Builder with
NextJS, React