Skip to content

Commit b9b11f0

Browse files
authored
feat(ui): extract block selector from blocks drawer (#14697)
Abstracts a `BlockSelector` component from the `BlocksDrawer` and exports for external use. Exposes a new `onSelect` callback to perform side effects.
1 parent 91d3e04 commit b9b11f0

File tree

7 files changed

+155
-110
lines changed

7 files changed

+155
-110
lines changed

packages/ui/src/exports/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export {
180180
} from '../../elements/FolderView/MoveDocToFolder/index.js'
181181

182182
export { BlocksDrawer } from '../../fields/Blocks/BlocksDrawer/index.js'
183+
export { BlockSelector } from '../../fields/Blocks/BlockSelector/index.js'
183184
export { SectionTitle } from '../../fields/Blocks/SectionTitle/index.js'
184185

185186
// fields
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
'use client'
2+
import type { I18nClient } from '@payloadcms/translations'
3+
import type { ClientBlock } from 'payload'
4+
5+
import { getTranslation } from '@payloadcms/translations'
6+
import React, { Fragment, useEffect, useMemo, useState } from 'react'
7+
8+
import { ThumbnailCard } from '../../../elements/ThumbnailCard/index.js'
9+
import { DefaultBlockImage } from '../../../graphics/DefaultBlockImage/index.js'
10+
import { useControllableState } from '../../../hooks/useControllableState.js'
11+
import { useConfig } from '../../../providers/Config/index.js'
12+
import { useTranslation } from '../../../providers/Translation/index.js'
13+
import { BlockSearch } from './BlockSearch/index.js'
14+
import './index.scss'
15+
16+
export type Props = {
17+
readonly blocks: (ClientBlock | string)[]
18+
readonly onSelect?: (blockType: string) => void
19+
/**
20+
* Control the search term state externally
21+
*/
22+
searchTerm?: string
23+
}
24+
25+
const baseClass = 'blocks-drawer'
26+
27+
const getBlockLabel = (block: ClientBlock, i18n: I18nClient) => {
28+
if (typeof block.labels.singular === 'string') {
29+
return block.labels.singular.toLowerCase()
30+
}
31+
if (typeof block.labels.singular === 'object') {
32+
return getTranslation(block.labels.singular, i18n).toLowerCase()
33+
}
34+
return ''
35+
}
36+
37+
export const BlockSelector: React.FC<Props> = (props) => {
38+
const { blocks, onSelect, searchTerm: searchTermFromProps } = props
39+
40+
const [searchTerm, setSearchTerm] = useControllableState(searchTermFromProps ?? '')
41+
42+
const [filteredBlocks, setFilteredBlocks] = useState(blocks)
43+
const { i18n } = useTranslation()
44+
const { config } = useConfig()
45+
46+
const blockGroups = useMemo(() => {
47+
const groups: Record<string, (ClientBlock | string)[]> = {
48+
_none: [],
49+
}
50+
51+
filteredBlocks.forEach((block) => {
52+
if (typeof block === 'object' && block.admin?.group) {
53+
const group = block.admin.group
54+
const label = typeof group === 'string' ? group : getTranslation(group, i18n)
55+
56+
if (Object.hasOwn(groups, label)) {
57+
groups[label].push(block)
58+
} else {
59+
groups[label] = [block]
60+
}
61+
} else {
62+
groups._none.push(block)
63+
}
64+
})
65+
66+
return groups
67+
}, [filteredBlocks, i18n])
68+
69+
useEffect(() => {
70+
const searchTermToUse = searchTerm.toLowerCase()
71+
72+
const matchingBlocks = blocks?.reduce((matchedBlocks, _block) => {
73+
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
74+
const blockLabel = getBlockLabel(block, i18n)
75+
if (blockLabel.includes(searchTermToUse)) {
76+
matchedBlocks.push(block)
77+
}
78+
return matchedBlocks
79+
}, [])
80+
81+
setFilteredBlocks(matchingBlocks)
82+
}, [searchTerm, blocks, i18n, config.blocksMap])
83+
84+
return (
85+
<Fragment>
86+
<BlockSearch setSearchTerm={setSearchTerm} />
87+
<div className={`${baseClass}__blocks-wrapper`}>
88+
<ul className={`${baseClass}__block-groups`}>
89+
{Object.entries(blockGroups).map(([groupLabel, groupBlocks]) =>
90+
!groupBlocks.length ? null : (
91+
<li
92+
className={[
93+
`${baseClass}__block-group`,
94+
groupLabel === '_none' && `${baseClass}__block-group-none`,
95+
]
96+
.filter(Boolean)
97+
.join(' ')}
98+
key={groupLabel}
99+
>
100+
{groupLabel !== '_none' && (
101+
<h3 className={`${baseClass}__block-group-label`}>{groupLabel}</h3>
102+
)}
103+
<ul className={`${baseClass}__blocks`}>
104+
{groupBlocks.map((_block, index) => {
105+
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
106+
107+
const { slug, imageAltText, imageURL, labels: blockLabels } = block
108+
109+
return (
110+
<li className={`${baseClass}__block`} key={index}>
111+
<ThumbnailCard
112+
alignLabel="center"
113+
label={getTranslation(blockLabels?.singular, i18n)}
114+
onClick={() => {
115+
if (typeof onSelect === 'function') {
116+
onSelect(slug)
117+
}
118+
}}
119+
thumbnail={
120+
<div className={`${baseClass}__default-image`}>
121+
{imageURL ? (
122+
<img alt={imageAltText} src={imageURL} />
123+
) : (
124+
<DefaultBlockImage />
125+
)}
126+
</div>
127+
}
128+
/>
129+
</li>
130+
)
131+
})}
132+
</ul>
133+
</li>
134+
),
135+
)}
136+
</ul>
137+
</div>
138+
</Fragment>
139+
)
140+
}
Lines changed: 12 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
11
'use client'
2-
import type { I18nClient } from '@payloadcms/translations'
32
import type { ClientBlock, Labels } from 'payload'
43

54
import { useModal } from '@faceless-ui/modal'
65
import { getTranslation } from '@payloadcms/translations'
7-
import React, { useEffect, useMemo, useState } from 'react'
6+
import React, { useEffect } from 'react'
87

98
import { Drawer } from '../../../elements/Drawer/index.js'
10-
import { ThumbnailCard } from '../../../elements/ThumbnailCard/index.js'
11-
import { DefaultBlockImage } from '../../../graphics/DefaultBlockImage/index.js'
12-
import { useConfig } from '../../../providers/Config/index.js'
139
import { useTranslation } from '../../../providers/Translation/index.js'
14-
import './index.scss'
15-
import { BlockSearch } from './BlockSearch/index.js'
10+
import { BlockSelector } from '../BlockSelector/index.js'
1611

1712
export type Props = {
1813
readonly addRow: (index: number, blockType?: string) => Promise<void> | void
@@ -22,125 +17,32 @@ export type Props = {
2217
readonly labels: Labels
2318
}
2419

25-
const baseClass = 'blocks-drawer'
26-
27-
const getBlockLabel = (block: ClientBlock, i18n: I18nClient) => {
28-
if (typeof block.labels.singular === 'string') {
29-
return block.labels.singular.toLowerCase()
30-
}
31-
if (typeof block.labels.singular === 'object') {
32-
return getTranslation(block.labels.singular, i18n).toLowerCase()
33-
}
34-
return ''
35-
}
36-
3720
export const BlocksDrawer: React.FC<Props> = (props) => {
3821
const { addRow, addRowIndex, blocks, drawerSlug, labels } = props
3922

40-
const [searchTerm, setSearchTerm] = useState('')
41-
const [filteredBlocks, setFilteredBlocks] = useState(blocks)
4223
const { closeModal, isModalOpen } = useModal()
4324
const { i18n, t } = useTranslation()
44-
const { config } = useConfig()
45-
46-
const blockGroups = useMemo(() => {
47-
const groups: Record<string, (ClientBlock | string)[]> = {
48-
_none: [],
49-
}
50-
filteredBlocks.forEach((block) => {
51-
if (typeof block === 'object' && block.admin?.group) {
52-
const group = block.admin.group
53-
const label = typeof group === 'string' ? group : getTranslation(group, i18n)
54-
55-
if (Object.hasOwn(groups, label)) {
56-
groups[label].push(block)
57-
} else {
58-
groups[label] = [block]
59-
}
60-
} else {
61-
groups._none.push(block)
62-
}
63-
})
64-
return groups
65-
}, [filteredBlocks, i18n])
25+
const [searchTermOverride, setSearchTermOverride] = React.useState('')
6626

6727
useEffect(() => {
6828
if (!isModalOpen(drawerSlug)) {
69-
setSearchTerm('')
29+
setSearchTermOverride('')
7030
}
7131
}, [isModalOpen, drawerSlug])
7232

73-
useEffect(() => {
74-
const searchTermToUse = searchTerm.toLowerCase()
75-
76-
const matchingBlocks = blocks?.reduce((matchedBlocks, _block) => {
77-
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
78-
const blockLabel = getBlockLabel(block, i18n)
79-
if (blockLabel.includes(searchTermToUse)) {
80-
matchedBlocks.push(block)
81-
}
82-
return matchedBlocks
83-
}, [])
84-
85-
setFilteredBlocks(matchingBlocks)
86-
}, [searchTerm, blocks, i18n, config.blocksMap])
87-
8833
return (
8934
<Drawer
9035
slug={drawerSlug}
9136
title={t('fields:addLabel', { label: getTranslation(labels.singular, i18n) })}
9237
>
93-
<BlockSearch setSearchTerm={setSearchTerm} />
94-
<div className={`${baseClass}__blocks-wrapper`}>
95-
<ul className={`${baseClass}__block-groups`}>
96-
{Object.entries(blockGroups).map(([groupLabel, groupBlocks]) =>
97-
!groupBlocks.length ? null : (
98-
<li
99-
className={[
100-
`${baseClass}__block-group`,
101-
groupLabel === '_none' && `${baseClass}__block-group-none`,
102-
]
103-
.filter(Boolean)
104-
.join(' ')}
105-
key={groupLabel}
106-
>
107-
{groupLabel !== '_none' && (
108-
<h3 className={`${baseClass}__block-group-label`}>{groupLabel}</h3>
109-
)}
110-
<ul className={`${baseClass}__blocks`}>
111-
{groupBlocks.map((_block, index) => {
112-
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
113-
114-
const { slug, imageAltText, imageURL, labels: blockLabels } = block
115-
116-
return (
117-
<li className={`${baseClass}__block`} key={index}>
118-
<ThumbnailCard
119-
alignLabel="center"
120-
label={getTranslation(blockLabels?.singular, i18n)}
121-
onClick={() => {
122-
void addRow(addRowIndex, slug)
123-
closeModal(drawerSlug)
124-
}}
125-
thumbnail={
126-
<div className={`${baseClass}__default-image`}>
127-
{imageURL ? (
128-
<img alt={imageAltText} src={imageURL} />
129-
) : (
130-
<DefaultBlockImage />
131-
)}
132-
</div>
133-
}
134-
/>
135-
</li>
136-
)
137-
})}
138-
</ul>
139-
</li>
140-
),
141-
)}
142-
</ul>
143-
</div>
38+
<BlockSelector
39+
blocks={blocks}
40+
onSelect={(slug) => {
41+
void addRow(addRowIndex, slug)
42+
closeModal(drawerSlug)
43+
}}
44+
searchTerm={searchTermOverride}
45+
/>
14446
</Drawer>
14547
)
14648
}

packages/ui/src/hooks/useControllableState.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ export function useControllableState<T, D>(
1414
propValue: T,
1515
fallbackValue: D,
1616
): [T extends NonNullable<T> ? T : D | NonNullable<T>, (value: ((prev: T) => T) | T) => void]
17+
1718
export function useControllableState<T>(propValue: T): [T, (value: ((prev: T) => T) | T) => void]
19+
1820
export function useControllableState<T, D>(
1921
propValue: T,
2022
fallbackValue?: D,

0 commit comments

Comments
 (0)