feat(ui): adds support for block groups (#11239)
<!-- Thank you for the PR! Please go through the checklist below and make sure you've completed all the steps. Please review the [CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md) document in this repository if you haven't already. The following items will ensure that your PR is handled as smoothly as possible: - PR Title must follow conventional commits format. For example, `feat: my new feature`, `fix(plugin-seo): my fix`. - Minimal description explained as if explained to someone not immediately familiar with the code. - Provide before/after screenshots or code diffs if applicable. - Link any related issues/discussions from GitHub or Discord. - Add review comments if necessary to explain to the reviewer the logic behind a change ### What? ### Why? ### How? Fixes # --> ### What? This PR introduces support for the `admin.group` property in block configs. This property enables blocks to be grouped under a common, potentially localized, label in the block drawer component. This makes it easier to sort through large collections of blocks. Previously, all blocks would be in one common layout. This PR also encompasses documentation changes and e2e tests to check for the rendering of group labels. ### Why? To make it easier to organize many blocks in block fields. ### How? By introducing a new `admin.group` property in block configs and assembling them in the blocks drawer component. Before:  After:  Demo: [Editing---Block-Field---Payload-groups-demo.webm](https://github.com/user-attachments/assets/2b351dc1-0d14-4a5b-ae71-bcd31fbb23df) Addresses #5609
This commit is contained in:
@@ -80,7 +80,8 @@ export const MyBlocksField: Field = {
|
||||
The Blocks Field inherits all of the default options from the base [Field Admin Config](./overview#admin-options), plus the following additional options:
|
||||
|
||||
| Option | Description |
|
||||
| ------------------- | ---------------------------------- |
|
||||
| ------------------- | -------------------------------------------------------------------------- |
|
||||
| **`group`** | Text or localization object used to group this Block in the Blocks Drawer. |
|
||||
| **`initCollapsed`** | Set the initial collapsed state |
|
||||
| **`isSortable`** | Disable order sorting by setting this value to `false` |
|
||||
|
||||
|
||||
@@ -107,9 +107,13 @@ export const createClientBlocks = ({
|
||||
clientBlock.imageURL = block.imageURL
|
||||
}
|
||||
|
||||
if (block.admin?.custom) {
|
||||
clientBlock.admin = {
|
||||
custom: block.admin.custom,
|
||||
if (block.admin?.custom || block.admin?.group) {
|
||||
clientBlock.admin = {}
|
||||
if (block.admin.custom) {
|
||||
clientBlock.admin.custom = block.admin.custom
|
||||
}
|
||||
if (block.admin.group) {
|
||||
clientBlock.admin.group = block.admin.group
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1373,6 +1373,7 @@ export type Block = {
|
||||
}
|
||||
/** Extension point to add your custom data. Available in server and client. */
|
||||
custom?: Record<string, any>
|
||||
group?: Record<string, string> | string
|
||||
jsx?: PayloadComponent
|
||||
}
|
||||
/** Extension point to add your custom data. Server only. */
|
||||
@@ -1401,7 +1402,7 @@ export type Block = {
|
||||
}
|
||||
|
||||
export type ClientBlock = {
|
||||
admin?: Pick<Block['admin'], 'custom'>
|
||||
admin?: Pick<Block['admin'], 'custom' | 'group'>
|
||||
fields: ClientField[]
|
||||
labels?: LabelsClient
|
||||
} & Pick<Block, 'imageAltText' | 'imageURL' | 'jsx' | 'slug'>
|
||||
|
||||
@@ -21,6 +21,32 @@
|
||||
padding-top: base(0.75);
|
||||
}
|
||||
|
||||
&__block-groups {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: base(1.5);
|
||||
}
|
||||
|
||||
&__block-group {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&__block-group-label {
|
||||
padding-bottom: base(0.5);
|
||||
}
|
||||
|
||||
&__block-group-none {
|
||||
order: 1;
|
||||
padding-top: base(1.5);
|
||||
border-top: 1px solid var(--theme-border-color);
|
||||
|
||||
&:only-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@include large-break {
|
||||
&__blocks {
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
@@ -31,10 +57,19 @@
|
||||
&__blocks-wrapper {
|
||||
padding-top: base(1.75);
|
||||
}
|
||||
|
||||
&__blocks {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: base(0.5);
|
||||
}
|
||||
|
||||
&__block-groups {
|
||||
gap: base(1.75);
|
||||
}
|
||||
|
||||
&__block-group-none {
|
||||
padding-top: base(1.75);
|
||||
}
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
@@ -45,6 +80,14 @@
|
||||
&__blocks {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
&__block-groups {
|
||||
gap: base(0.75);
|
||||
}
|
||||
|
||||
&__block-group-none {
|
||||
padding-top: base(0.75);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ClientBlock, Labels } from 'payload'
|
||||
|
||||
import { useModal } from '@faceless-ui/modal'
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Drawer } from '../../../elements/Drawer/index.js'
|
||||
import { ThumbnailCard } from '../../../elements/ThumbnailCard/index.js'
|
||||
@@ -43,6 +43,27 @@ export const BlocksDrawer: React.FC<Props> = (props) => {
|
||||
const { i18n, t } = useTranslation()
|
||||
const { config } = useConfig()
|
||||
|
||||
const blockGroups = useMemo(() => {
|
||||
const groups: Record<string, (ClientBlock | string)[]> = {
|
||||
_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(() => {
|
||||
if (!isModalOpen(drawerSlug)) {
|
||||
setSearchTerm('')
|
||||
@@ -71,8 +92,23 @@ export const BlocksDrawer: React.FC<Props> = (props) => {
|
||||
>
|
||||
<BlockSearch setSearchTerm={setSearchTerm} />
|
||||
<div className={`${baseClass}__blocks-wrapper`}>
|
||||
<ul className={`${baseClass}__block-groups`}>
|
||||
{Object.entries(blockGroups).map(([groupLabel, groupBlocks]) =>
|
||||
!groupBlocks.length ? null : (
|
||||
<li
|
||||
className={[
|
||||
`${baseClass}__block-group`,
|
||||
groupLabel === '_none' && `${baseClass}__block-group-none`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
key={groupLabel}
|
||||
>
|
||||
{groupLabel !== '_none' && (
|
||||
<h3 className={`${baseClass}__block-group-label`}>{groupLabel}</h3>
|
||||
)}
|
||||
<ul className={`${baseClass}__blocks`}>
|
||||
{filteredBlocks?.map((_block, index) => {
|
||||
{groupBlocks.map((_block, index) => {
|
||||
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
|
||||
|
||||
const { slug, imageAltText, imageURL, labels: blockLabels } = block
|
||||
@@ -100,6 +136,10 @@ export const BlocksDrawer: React.FC<Props> = (props) => {
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
@@ -344,4 +344,32 @@ describe('Block fields', () => {
|
||||
expect(await field.count()).toEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('block groups', () => {
|
||||
test('should render group labels', async () => {
|
||||
await page.goto(url.create)
|
||||
const addButton = page.locator('#field-groupedBlocks > .blocks-field__drawer-toggler')
|
||||
await addButton.click()
|
||||
|
||||
const blocksDrawer = page.locator('[id^=drawer_1_blocks-drawer-]')
|
||||
await expect(blocksDrawer).toBeVisible()
|
||||
|
||||
const groupLabel = blocksDrawer.locator('.blocks-drawer__block-group-label').first()
|
||||
await expect(groupLabel).toBeVisible()
|
||||
await expect(groupLabel).toHaveText('Group')
|
||||
})
|
||||
|
||||
test('should render localized group labels', async () => {
|
||||
await page.goto(url.create)
|
||||
const addButton = page.locator('#field-groupedBlocks > .blocks-field__drawer-toggler')
|
||||
await addButton.click()
|
||||
|
||||
const blocksDrawer = page.locator('[id^=drawer_1_blocks-drawer-]')
|
||||
await expect(blocksDrawer).toBeVisible()
|
||||
|
||||
const groupLabel = blocksDrawer.locator('.blocks-drawer__block-group-label').nth(1)
|
||||
await expect(groupLabel).toBeVisible()
|
||||
await expect(groupLabel).toHaveText('Group in en')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -424,6 +424,63 @@ const BlockFields: CollectionConfig = {
|
||||
blockReferences: ['localizedTextReference2'],
|
||||
blocks: [],
|
||||
},
|
||||
{
|
||||
name: 'groupedBlocks',
|
||||
type: 'blocks',
|
||||
admin: {
|
||||
description: 'The purpose of this field is to test Block groups.',
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
slug: 'blockWithGroupOne',
|
||||
admin: {
|
||||
group: 'Group',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'blockWithGroupTwo',
|
||||
admin: {
|
||||
group: 'Group',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'blockWithLocalizedGroup',
|
||||
admin: {
|
||||
group: {
|
||||
en: 'Group in en',
|
||||
es: 'Group in es',
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'blockWithoutGroup',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -834,6 +834,37 @@ export interface BlockField {
|
||||
deduplicatedBlocks2?: ConfigBlockTest[] | null;
|
||||
localizedReferencesLocalizedBlock?: LocalizedTextReference[] | null;
|
||||
localizedReferences?: LocalizedTextReference2[] | null;
|
||||
/**
|
||||
* The purpose of this field is to test Block groups.
|
||||
*/
|
||||
groupedBlocks?:
|
||||
| (
|
||||
| {
|
||||
text?: string | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'blockWithGroupOne';
|
||||
}
|
||||
| {
|
||||
text?: string | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'blockWithGroupTwo';
|
||||
}
|
||||
| {
|
||||
text?: string | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'blockWithLocalizedGroup';
|
||||
}
|
||||
| {
|
||||
text?: string | null;
|
||||
id?: string | null;
|
||||
blockName?: string | null;
|
||||
blockType: 'blockWithoutGroup';
|
||||
}
|
||||
)[]
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -2587,6 +2618,38 @@ export interface BlockFieldsSelect<T extends boolean = true> {
|
||||
deduplicatedBlocks2?: T | {};
|
||||
localizedReferencesLocalizedBlock?: T | {};
|
||||
localizedReferences?: T | {};
|
||||
groupedBlocks?:
|
||||
| T
|
||||
| {
|
||||
blockWithGroupOne?:
|
||||
| T
|
||||
| {
|
||||
text?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
blockWithGroupTwo?:
|
||||
| T
|
||||
| {
|
||||
text?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
blockWithLocalizedGroup?:
|
||||
| T
|
||||
| {
|
||||
text?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
blockWithoutGroup?:
|
||||
| T
|
||||
| {
|
||||
text?: T;
|
||||
id?: T;
|
||||
blockName?: T;
|
||||
};
|
||||
};
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user