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:

![Editing-Block-Field-Payload--before](https://github.com/user-attachments/assets/fb0c887b-ee47-46a1-a249-c4a4b7a5c13c)

After:

![Editing-Block-Field-Payload--after](https://github.com/user-attachments/assets/046d5a6f-3108-4464-ac69-8b7afcf27094)

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:
Said Akhrarov
2025-02-20 14:51:47 -05:00
committed by GitHub
parent 460d50baa3
commit 22f61ad79e
8 changed files with 270 additions and 33 deletions

View File

@@ -79,10 +79,11 @@ 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: 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 | | Option | Description |
| ------------------- | ---------------------------------- | | ------------------- | -------------------------------------------------------------------------- |
| **`initCollapsed`** | Set the initial collapsed state | | **`group`** | Text or localization object used to group this Block in the Blocks Drawer. |
| **`isSortable`** | Disable order sorting by setting this value to `false` | | **`initCollapsed`** | Set the initial collapsed state |
| **`isSortable`** | Disable order sorting by setting this value to `false` |
#### Customizing the way your block is rendered in Lexical #### Customizing the way your block is rendered in Lexical

View File

@@ -107,9 +107,13 @@ export const createClientBlocks = ({
clientBlock.imageURL = block.imageURL clientBlock.imageURL = block.imageURL
} }
if (block.admin?.custom) { if (block.admin?.custom || block.admin?.group) {
clientBlock.admin = { clientBlock.admin = {}
custom: block.admin.custom, if (block.admin.custom) {
clientBlock.admin.custom = block.admin.custom
}
if (block.admin.group) {
clientBlock.admin.group = block.admin.group
} }
} }

View File

@@ -1373,6 +1373,7 @@ export type Block = {
} }
/** Extension point to add your custom data. Available in server and client. */ /** Extension point to add your custom data. Available in server and client. */
custom?: Record<string, any> custom?: Record<string, any>
group?: Record<string, string> | string
jsx?: PayloadComponent jsx?: PayloadComponent
} }
/** Extension point to add your custom data. Server only. */ /** Extension point to add your custom data. Server only. */
@@ -1401,7 +1402,7 @@ export type Block = {
} }
export type ClientBlock = { export type ClientBlock = {
admin?: Pick<Block['admin'], 'custom'> admin?: Pick<Block['admin'], 'custom' | 'group'>
fields: ClientField[] fields: ClientField[]
labels?: LabelsClient labels?: LabelsClient
} & Pick<Block, 'imageAltText' | 'imageURL' | 'jsx' | 'slug'> } & Pick<Block, 'imageAltText' | 'imageURL' | 'jsx' | 'slug'>

View File

@@ -21,6 +21,32 @@
padding-top: base(0.75); 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 { @include large-break {
&__blocks { &__blocks {
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
@@ -31,10 +57,19 @@
&__blocks-wrapper { &__blocks-wrapper {
padding-top: base(1.75); padding-top: base(1.75);
} }
&__blocks { &__blocks {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: base(0.5); gap: base(0.5);
} }
&__block-groups {
gap: base(1.75);
}
&__block-group-none {
padding-top: base(1.75);
}
} }
@include small-break { @include small-break {
@@ -45,6 +80,14 @@
&__blocks { &__blocks {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
&__block-groups {
gap: base(0.75);
}
&__block-group-none {
padding-top: base(0.75);
}
} }
} }
} }

View File

@@ -4,7 +4,7 @@ import type { ClientBlock, Labels } from 'payload'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations' 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 { Drawer } from '../../../elements/Drawer/index.js'
import { ThumbnailCard } from '../../../elements/ThumbnailCard/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 { i18n, t } = useTranslation()
const { config } = useConfig() 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(() => { useEffect(() => {
if (!isModalOpen(drawerSlug)) { if (!isModalOpen(drawerSlug)) {
setSearchTerm('') setSearchTerm('')
@@ -71,34 +92,53 @@ export const BlocksDrawer: React.FC<Props> = (props) => {
> >
<BlockSearch setSearchTerm={setSearchTerm} /> <BlockSearch setSearchTerm={setSearchTerm} />
<div className={`${baseClass}__blocks-wrapper`}> <div className={`${baseClass}__blocks-wrapper`}>
<ul className={`${baseClass}__blocks`}> <ul className={`${baseClass}__block-groups`}>
{filteredBlocks?.map((_block, index) => { {Object.entries(blockGroups).map(([groupLabel, groupBlocks]) =>
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block !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`}>
{groupBlocks.map((_block, index) => {
const block = typeof _block === 'string' ? config.blocksMap[_block] : _block
const { slug, imageAltText, imageURL, labels: blockLabels } = block const { slug, imageAltText, imageURL, labels: blockLabels } = block
return ( return (
<li className={`${baseClass}__block`} key={index}> <li className={`${baseClass}__block`} key={index}>
<ThumbnailCard <ThumbnailCard
alignLabel="center" alignLabel="center"
label={getTranslation(blockLabels?.singular, i18n)} label={getTranslation(blockLabels?.singular, i18n)}
onClick={() => { onClick={() => {
void addRow(addRowIndex, slug) void addRow(addRowIndex, slug)
closeModal(drawerSlug) closeModal(drawerSlug)
}} }}
thumbnail={ thumbnail={
imageURL ? ( imageURL ? (
<img alt={imageAltText} src={imageURL} /> <img alt={imageAltText} src={imageURL} />
) : ( ) : (
<div className={`${baseClass}__default-image`}> <div className={`${baseClass}__default-image`}>
<DefaultBlockImage /> <DefaultBlockImage />
</div> </div>
)
}
/>
</li>
) )
} })}
/> </ul>
</li> </li>
) ),
})} )}
</ul> </ul>
</div> </div>
</Drawer> </Drawer>

View File

@@ -344,4 +344,32 @@ describe('Block fields', () => {
expect(await field.count()).toEqual(0) 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')
})
})
}) })

View File

@@ -424,6 +424,63 @@ const BlockFields: CollectionConfig = {
blockReferences: ['localizedTextReference2'], blockReferences: ['localizedTextReference2'],
blocks: [], 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',
},
],
},
],
},
], ],
} }

View File

@@ -834,6 +834,37 @@ export interface BlockField {
deduplicatedBlocks2?: ConfigBlockTest[] | null; deduplicatedBlocks2?: ConfigBlockTest[] | null;
localizedReferencesLocalizedBlock?: LocalizedTextReference[] | null; localizedReferencesLocalizedBlock?: LocalizedTextReference[] | null;
localizedReferences?: LocalizedTextReference2[] | 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; updatedAt: string;
createdAt: string; createdAt: string;
} }
@@ -2587,6 +2618,38 @@ export interface BlockFieldsSelect<T extends boolean = true> {
deduplicatedBlocks2?: T | {}; deduplicatedBlocks2?: T | {};
localizedReferencesLocalizedBlock?: T | {}; localizedReferencesLocalizedBlock?: T | {};
localizedReferences?: 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; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }