diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index b149f57f4ff..be38350fa1d 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -180,6 +180,7 @@ export { } from '../../elements/FolderView/MoveDocToFolder/index.js' export { BlocksDrawer } from '../../fields/Blocks/BlocksDrawer/index.js' +export { BlockSelector } from '../../fields/Blocks/BlockSelector/index.js' export { SectionTitle } from '../../fields/Blocks/SectionTitle/index.js' // fields diff --git a/packages/ui/src/fields/Blocks/BlocksDrawer/BlockSearch/index.scss b/packages/ui/src/fields/Blocks/BlockSelector/BlockSearch/index.scss similarity index 100% rename from packages/ui/src/fields/Blocks/BlocksDrawer/BlockSearch/index.scss rename to packages/ui/src/fields/Blocks/BlockSelector/BlockSearch/index.scss diff --git a/packages/ui/src/fields/Blocks/BlocksDrawer/BlockSearch/index.tsx b/packages/ui/src/fields/Blocks/BlockSelector/BlockSearch/index.tsx similarity index 100% rename from packages/ui/src/fields/Blocks/BlocksDrawer/BlockSearch/index.tsx rename to packages/ui/src/fields/Blocks/BlockSelector/BlockSearch/index.tsx diff --git a/packages/ui/src/fields/Blocks/BlocksDrawer/index.scss b/packages/ui/src/fields/Blocks/BlockSelector/index.scss similarity index 100% rename from packages/ui/src/fields/Blocks/BlocksDrawer/index.scss rename to packages/ui/src/fields/Blocks/BlockSelector/index.scss diff --git a/packages/ui/src/fields/Blocks/BlockSelector/index.tsx b/packages/ui/src/fields/Blocks/BlockSelector/index.tsx new file mode 100644 index 00000000000..9e0fef25eac --- /dev/null +++ b/packages/ui/src/fields/Blocks/BlockSelector/index.tsx @@ -0,0 +1,140 @@ +'use client' +import type { I18nClient } from '@payloadcms/translations' +import type { ClientBlock } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import React, { Fragment, useEffect, useMemo, useState } from 'react' + +import { ThumbnailCard } from '../../../elements/ThumbnailCard/index.js' +import { DefaultBlockImage } from '../../../graphics/DefaultBlockImage/index.js' +import { useControllableState } from '../../../hooks/useControllableState.js' +import { useConfig } from '../../../providers/Config/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { BlockSearch } from './BlockSearch/index.js' +import './index.scss' + +export type Props = { + readonly blocks: (ClientBlock | string)[] + readonly onSelect?: (blockType: string) => void + /** + * Control the search term state externally + */ + searchTerm?: string +} + +const baseClass = 'blocks-drawer' + +const getBlockLabel = (block: ClientBlock, i18n: I18nClient) => { + if (typeof block.labels.singular === 'string') { + return block.labels.singular.toLowerCase() + } + if (typeof block.labels.singular === 'object') { + return getTranslation(block.labels.singular, i18n).toLowerCase() + } + return '' +} + +export const BlockSelector: React.FC = (props) => { + const { blocks, onSelect, searchTerm: searchTermFromProps } = props + + const [searchTerm, setSearchTerm] = useControllableState(searchTermFromProps ?? '') + + const [filteredBlocks, setFilteredBlocks] = useState(blocks) + const { i18n } = useTranslation() + const { config } = useConfig() + + const blockGroups = useMemo(() => { + const groups: Record = { + _none: [], + } + + filteredBlocks.forEach((block) => { + if (typeof block === 'object' && block.admin?.group) { + const group = block.admin.group + const label = typeof group === 'string' ? group : getTranslation(group, i18n) + + if (Object.hasOwn(groups, label)) { + groups[label].push(block) + } else { + groups[label] = [block] + } + } else { + groups._none.push(block) + } + }) + + return groups + }, [filteredBlocks, i18n]) + + useEffect(() => { + const searchTermToUse = searchTerm.toLowerCase() + + const matchingBlocks = blocks?.reduce((matchedBlocks, _block) => { + const block = typeof _block === 'string' ? config.blocksMap[_block] : _block + const blockLabel = getBlockLabel(block, i18n) + if (blockLabel.includes(searchTermToUse)) { + matchedBlocks.push(block) + } + return matchedBlocks + }, []) + + setFilteredBlocks(matchingBlocks) + }, [searchTerm, blocks, i18n, config.blocksMap]) + + return ( + + +
+
    + {Object.entries(blockGroups).map(([groupLabel, groupBlocks]) => + !groupBlocks.length ? null : ( +
  • + {groupLabel !== '_none' && ( +

    {groupLabel}

    + )} +
      + {groupBlocks.map((_block, index) => { + const block = typeof _block === 'string' ? config.blocksMap[_block] : _block + + const { slug, imageAltText, imageURL, labels: blockLabels } = block + + return ( +
    • + { + if (typeof onSelect === 'function') { + onSelect(slug) + } + }} + thumbnail={ +
      + {imageURL ? ( + {imageAltText} + ) : ( + + )} +
      + } + /> +
    • + ) + })} +
    +
  • + ), + )} +
+
+
+ ) +} diff --git a/packages/ui/src/fields/Blocks/BlocksDrawer/index.tsx b/packages/ui/src/fields/Blocks/BlocksDrawer/index.tsx index 9ce79c32a67..c5ba76c44d4 100644 --- a/packages/ui/src/fields/Blocks/BlocksDrawer/index.tsx +++ b/packages/ui/src/fields/Blocks/BlocksDrawer/index.tsx @@ -1,18 +1,13 @@ 'use client' -import type { I18nClient } from '@payloadcms/translations' import type { ClientBlock, Labels } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect } from 'react' import { Drawer } from '../../../elements/Drawer/index.js' -import { ThumbnailCard } from '../../../elements/ThumbnailCard/index.js' -import { DefaultBlockImage } from '../../../graphics/DefaultBlockImage/index.js' -import { useConfig } from '../../../providers/Config/index.js' import { useTranslation } from '../../../providers/Translation/index.js' -import './index.scss' -import { BlockSearch } from './BlockSearch/index.js' +import { BlockSelector } from '../BlockSelector/index.js' export type Props = { readonly addRow: (index: number, blockType?: string) => Promise | void @@ -22,125 +17,32 @@ export type Props = { readonly labels: Labels } -const baseClass = 'blocks-drawer' - -const getBlockLabel = (block: ClientBlock, i18n: I18nClient) => { - if (typeof block.labels.singular === 'string') { - return block.labels.singular.toLowerCase() - } - if (typeof block.labels.singular === 'object') { - return getTranslation(block.labels.singular, i18n).toLowerCase() - } - return '' -} - export const BlocksDrawer: React.FC = (props) => { const { addRow, addRowIndex, blocks, drawerSlug, labels } = props - const [searchTerm, setSearchTerm] = useState('') - const [filteredBlocks, setFilteredBlocks] = useState(blocks) const { closeModal, isModalOpen } = useModal() const { i18n, t } = useTranslation() - const { config } = useConfig() - - const blockGroups = useMemo(() => { - const groups: Record = { - _none: [], - } - filteredBlocks.forEach((block) => { - if (typeof block === 'object' && block.admin?.group) { - const group = block.admin.group - const label = typeof group === 'string' ? group : getTranslation(group, i18n) - - if (Object.hasOwn(groups, label)) { - groups[label].push(block) - } else { - groups[label] = [block] - } - } else { - groups._none.push(block) - } - }) - return groups - }, [filteredBlocks, i18n]) + const [searchTermOverride, setSearchTermOverride] = React.useState('') useEffect(() => { if (!isModalOpen(drawerSlug)) { - setSearchTerm('') + setSearchTermOverride('') } }, [isModalOpen, drawerSlug]) - useEffect(() => { - const searchTermToUse = searchTerm.toLowerCase() - - const matchingBlocks = blocks?.reduce((matchedBlocks, _block) => { - const block = typeof _block === 'string' ? config.blocksMap[_block] : _block - const blockLabel = getBlockLabel(block, i18n) - if (blockLabel.includes(searchTermToUse)) { - matchedBlocks.push(block) - } - return matchedBlocks - }, []) - - setFilteredBlocks(matchingBlocks) - }, [searchTerm, blocks, i18n, config.blocksMap]) - return ( - -
-
    - {Object.entries(blockGroups).map(([groupLabel, groupBlocks]) => - !groupBlocks.length ? null : ( -
  • - {groupLabel !== '_none' && ( -

    {groupLabel}

    - )} -
      - {groupBlocks.map((_block, index) => { - const block = typeof _block === 'string' ? config.blocksMap[_block] : _block - - const { slug, imageAltText, imageURL, labels: blockLabels } = block - - return ( -
    • - { - void addRow(addRowIndex, slug) - closeModal(drawerSlug) - }} - thumbnail={ -
      - {imageURL ? ( - {imageAltText} - ) : ( - - )} -
      - } - /> -
    • - ) - })} -
    -
  • - ), - )} -
-
+ { + void addRow(addRowIndex, slug) + closeModal(drawerSlug) + }} + searchTerm={searchTermOverride} + />
) } diff --git a/packages/ui/src/hooks/useControllableState.ts b/packages/ui/src/hooks/useControllableState.ts index 1688d783b53..2742a42200f 100644 --- a/packages/ui/src/hooks/useControllableState.ts +++ b/packages/ui/src/hooks/useControllableState.ts @@ -14,7 +14,9 @@ export function useControllableState( propValue: T, fallbackValue: D, ): [T extends NonNullable ? T : D | NonNullable, (value: ((prev: T) => T) | T) => void] + export function useControllableState(propValue: T): [T, (value: ((prev: T) => T) | T) => void] + export function useControllableState( propValue: T, fallbackValue?: D,