feat(ui): adds admin.components.listMenuItems option (#11230)

### What?
Adds new option `admin.components.listMenuItems` to allow custom
components to be injected after the existing list controls in the
collection list view.

### Why?
Needed to facilitate import/export plugin.

#### Testing

Use `pnpm dev admin` to see example component and see test added to
`test/admin/e2e/list-view`.


## Update since feature was reverted
The custom list controls and now rendered with no surrounding padding or
border radius.

<img width="596" alt="Screenshot 2025-02-17 at 5 06 44 PM"
src="https://github.com/user-attachments/assets/57209367-5433-4a4c-8797-0f9671da15c8"
/>

---------

Co-authored-by: Dan Ribbens <dan.ribbens@gmail.com>
This commit is contained in:
Jessica Chowdhury
2025-02-18 14:35:27 +00:00
committed by GitHub
parent 117949b8d9
commit 8a2b712287
49 changed files with 161 additions and 10 deletions

View File

@@ -153,11 +153,12 @@ export const MyCollection: CollectionConfig = {
The following options are available:
| Option | Description |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| --------------------- |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `afterList` | An array of components to inject _after_ the built-in List View. [More details](../custom-components/list-view#afterlist). |
| `afterListTable` | An array of components to inject _after_ the built-in List View's table. [More details](../custom-components/list-view#afterlisttable). |
| `beforeList` | An array of components to inject _before_ the built-in List View. [More details](../custom-components/list-view#beforelist). |
| `beforeListTable` | An array of components to inject _before_ the built-in List View's table. [More details](../custom-components/list-view#beforelisttable). |
| `listMenuItems` | An array of components to render within a menu next to the List Controls (after the Columns and Filters options) |
| `Description` | A component to render below the Collection label in the List View. An alternative to the `admin.description` property. [More details](../custom-components/list-view#description). |
| `edit` | Override specific components within the Edit View. [More details](#edit-view-options). |
| `views` | Override or create new views within the Admin Panel. [More details](../custom-components/custom-views). |

View File

@@ -44,6 +44,18 @@ export const renderListViewSlots = ({
})
}
const listMenuItems = collectionConfig.admin.components?.listMenuItems
if (Array.isArray(listMenuItems)) {
result.listMenuItems = [
RenderServerComponent({
clientProps,
Component: listMenuItems,
importMap: payload.importMap,
serverProps,
}),
]
}
if (collectionConfig.admin.components?.afterListTable) {
result.AfterListTable = RenderServerComponent({
clientProps: clientProps satisfies AfterListTableClientProps,

View File

@@ -14,6 +14,7 @@ export type ListViewSlots = {
BeforeList?: React.ReactNode
BeforeListTable?: React.ReactNode
Description?: React.ReactNode
listMenuItems?: React.ReactNode[]
Table: React.ReactNode
}

View File

@@ -30,6 +30,7 @@ export function iterateCollections({
})
addToImportMap(collection.admin?.components?.afterList)
addToImportMap(collection.admin?.components?.listMenuItems)
addToImportMap(collection.admin?.components?.afterListTable)
addToImportMap(collection.admin?.components?.beforeList)
addToImportMap(collection.admin?.components?.beforeListTable)

View File

@@ -304,6 +304,7 @@ export type CollectionAdminOptions = {
*/
Upload?: CustomUpload
}
listMenuItems?: CustomComponent[]
views?: {
/**
* Set to a React component to replace the entire Edit View, including all nested routes.

View File

@@ -209,6 +209,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
'general:loading',
'general:locale',
'general:menu',
'general:moreOptions',
'general:moveDown',
'general:moveUp',
'general:next',

View File

@@ -266,6 +266,7 @@ export const arTranslations: DefaultTranslationsObject = {
locale: 'اللّغة',
locales: 'اللّغات',
menu: 'قائمة',
moreOptions: 'خيارات أكثر',
moveDown: 'التّحريك إلى الأسفل',
moveUp: 'التّحريك إلى الأعلى',
newPassword: 'كلمة مرور جديدة',

View File

@@ -270,6 +270,7 @@ export const azTranslations: DefaultTranslationsObject = {
locale: 'Lokal',
locales: 'Dillər',
menu: 'Menyu',
moreOptions: 'Daha çox seçimlər',
moveDown: 'Aşağı hərəkət et',
moveUp: 'Yuxarı hərəkət et',
newPassword: 'Yeni şifrə',

View File

@@ -269,6 +269,7 @@ export const bgTranslations: DefaultTranslationsObject = {
locale: 'Локализация',
locales: 'Локализации',
menu: 'Меню',
moreOptions: 'Повече опции',
moveDown: 'Надолу',
moveUp: 'Нагоре',
newPassword: 'Нова парола',

View File

@@ -270,6 +270,7 @@ export const caTranslations: DefaultTranslationsObject = {
locale: 'Idioma',
locales: 'Idiomes',
menu: 'Menu',
moreOptions: 'Més opcions',
moveDown: 'Mou avall',
moveUp: 'Move amunt',
newPassword: 'Nova contrasenya',

View File

@@ -268,6 +268,7 @@ export const csTranslations: DefaultTranslationsObject = {
locale: 'Místní verze',
locales: 'Lokality',
menu: 'Jídelní lístek',
moreOptions: 'Více možností',
moveDown: 'Posunout dolů',
moveUp: 'Posunout nahoru',
newPassword: 'Nové heslo',

View File

@@ -268,6 +268,7 @@ export const daTranslations: DefaultTranslationsObject = {
locale: 'Lokalitet',
locales: 'Lokaliteter',
menu: 'Menu',
moreOptions: 'Flere muligheder',
moveDown: 'Ryk ned',
moveUp: 'Ryk op',
newPassword: 'Ny adgangskode',

View File

@@ -274,6 +274,7 @@ export const deTranslations: DefaultTranslationsObject = {
locale: 'Sprache',
locales: 'Sprachen',
menu: 'Menü',
moreOptions: 'Mehr Optionen',
moveDown: 'Nach unten bewegen',
moveUp: 'Nach oben bewegen',
newPassword: 'Neues Passwort',

View File

@@ -270,6 +270,7 @@ export const enTranslations = {
locale: 'Locale',
locales: 'Locales',
menu: 'Menu',
moreOptions: 'More options',
moveDown: 'Move Down',
moveUp: 'Move Up',
newPassword: 'New Password',

View File

@@ -274,6 +274,7 @@ export const esTranslations: DefaultTranslationsObject = {
locale: 'Regional',
locales: 'Locales',
menu: 'Menú',
moreOptions: 'Más opciones',
moveDown: 'Mover abajo',
moveUp: 'Mover arriba',
newPassword: 'Nueva contraseña',

View File

@@ -267,6 +267,7 @@ export const etTranslations: DefaultTranslationsObject = {
locale: 'Keel',
locales: 'Keeled',
menu: 'Menüü',
moreOptions: 'Rohkem valikuid',
moveDown: 'Liiguta alla',
moveUp: 'Liiguta üles',
newPassword: 'Uus parool',

View File

@@ -268,6 +268,7 @@ export const faTranslations: DefaultTranslationsObject = {
locale: 'زبان',
locales: 'زبان‌ها',
menu: 'منو',
moreOptions: 'گزینه‌های بیشتر',
moveDown: 'حرکت به پایین',
moveUp: 'حرکت به بالا',
newPassword: 'گذرواژه تازه',

View File

@@ -277,6 +277,7 @@ export const frTranslations: DefaultTranslationsObject = {
locale: 'Paramètres régionaux',
locales: 'Paramètres régionaux',
menu: 'Menu',
moreOptions: "Plus d'options",
moveDown: 'Déplacer vers le bas',
moveUp: 'Déplacer vers le haut',
newPassword: 'Nouveau mot de passe',

View File

@@ -264,6 +264,7 @@ export const heTranslations: DefaultTranslationsObject = {
locale: 'שפה',
locales: 'שפות',
menu: 'תפריט',
moreOptions: 'אפשרויות נוספות',
moveDown: 'הזז למטה',
moveUp: 'הזז למעלה',
newPassword: 'סיסמה חדשה',

View File

@@ -270,6 +270,7 @@ export const hrTranslations: DefaultTranslationsObject = {
locale: 'Jezik',
locales: 'Prijevodi',
menu: 'Izbornik',
moreOptions: 'Više opcija',
moveDown: 'Pomakni dolje',
moveUp: 'Pomakni gore',
newPassword: 'Nova lozinka',

View File

@@ -272,6 +272,7 @@ export const huTranslations: DefaultTranslationsObject = {
locale: 'Nyelv',
locales: 'Nyelvek',
menu: 'Menü',
moreOptions: 'Több opció',
moveDown: 'Mozgatás lefelé',
moveUp: 'Mozgatás felfelé',
newPassword: 'Új jelszó',

View File

@@ -273,6 +273,7 @@ export const itTranslations: DefaultTranslationsObject = {
locale: 'Locale',
locales: 'Localizzazioni',
menu: 'Menù',
moreOptions: 'Più opzioni',
moveDown: 'Sposta sotto',
moveUp: 'Sposta sopra',
newPassword: 'Nuova Password',

View File

@@ -270,6 +270,7 @@ export const jaTranslations: DefaultTranslationsObject = {
locale: 'ロケール',
locales: 'ロケール',
menu: 'メニュー',
moreOptions: 'より多くのオプション',
moveDown: '下へ移動',
moveUp: '上へ移動',
newPassword: '新しいパスワード',

View File

@@ -268,6 +268,7 @@ export const koTranslations: DefaultTranslationsObject = {
locale: 'locale',
locales: 'locale',
menu: '메뉴',
moreOptions: '더 많은 옵션',
moveDown: '아래로 이동',
moveUp: '위로 이동',
newPassword: '새 비밀번호',

View File

@@ -272,6 +272,7 @@ export const myTranslations: DefaultTranslationsObject = {
locale: 'ဒေသ',
locales: 'Locales',
menu: 'မီနူး',
moreOptions: 'ပိုမိုများစွာရွေးချယ်ခွင့်',
moveDown: 'Move Down',
moveUp: 'Move Up',
newPassword: 'စကားဝှက် အသစ်',

View File

@@ -270,6 +270,7 @@ export const nbTranslations: DefaultTranslationsObject = {
locale: 'Lokalitet',
locales: 'Språk',
menu: 'Meny',
moreOptions: 'Flere alternativer',
moveDown: 'Flytt ned',
moveUp: 'Flytt opp',
newPassword: 'Nytt passord',

View File

@@ -273,6 +273,7 @@ export const nlTranslations: DefaultTranslationsObject = {
locale: 'Taal',
locales: 'Landinstellingen',
menu: 'Menu',
moreOptions: 'Meer opties',
moveDown: 'Verplaats naar beneden',
moveUp: 'Verplaats naar boven',
newPassword: 'Nieuw wachtwoord',

View File

@@ -270,6 +270,7 @@ export const plTranslations: DefaultTranslationsObject = {
locale: 'Ustawienia regionalne',
locales: 'Ustawienia regionalne',
menu: 'Menu',
moreOptions: 'Więcej opcji',
moveDown: 'Przesuń niżej',
moveUp: 'Przesuń wyżej',
newPassword: 'Nowe hasło',

View File

@@ -270,6 +270,7 @@ export const ptTranslations: DefaultTranslationsObject = {
locale: 'Local',
locales: 'Localizações',
menu: 'Cardápio',
moreOptions: 'Mais opções',
moveDown: 'Mover para Baixo',
moveUp: 'Mover para Cima',
newPassword: 'Nova Senha',

View File

@@ -274,6 +274,7 @@ export const roTranslations: DefaultTranslationsObject = {
locale: 'Localitate',
locales: 'Localuri',
menu: 'Meniu',
moreOptions: 'Mai multe opțiuni',
moveDown: 'Mutați în jos',
moveUp: 'Mutați în sus',
newPassword: 'Parolă nouă',

View File

@@ -270,6 +270,7 @@ export const rsTranslations: DefaultTranslationsObject = {
locale: 'Језик',
locales: 'Преводи',
menu: 'Мени',
moreOptions: 'Više opcija',
moveDown: 'Помери доле',
moveUp: 'Помери горе',
newPassword: 'Нова лозинка',

View File

@@ -270,6 +270,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = {
locale: 'Jezik',
locales: 'Prevodi',
menu: 'Meni',
moreOptions: 'Više opcija',
moveDown: 'Pomeri dole',
moveUp: 'Pomeri gore',
newPassword: 'Nova lozinka',

View File

@@ -272,6 +272,7 @@ export const ruTranslations: DefaultTranslationsObject = {
locale: 'Локаль',
locales: 'Локали',
menu: 'Меню',
moreOptions: 'Больше вариантов',
moveDown: 'Сдвинуть вниз',
moveUp: 'Сдвинуть вверх',
newPassword: 'Новый пароль',

View File

@@ -271,6 +271,7 @@ export const skTranslations: DefaultTranslationsObject = {
locale: 'Jazyk',
locales: 'Jazyky',
menu: 'Menu',
moreOptions: 'Viac možností',
moveDown: 'Presunúť dolu',
moveUp: 'Presunúť hore',
newPassword: 'Nové heslo',

View File

@@ -269,6 +269,7 @@ export const slTranslations: DefaultTranslationsObject = {
locale: 'Jezik',
locales: 'Jeziki',
menu: 'Meni',
moreOptions: 'Več možnosti',
moveDown: 'Premakni dol',
moveUp: 'Premakni gor',
newPassword: 'Novo geslo',

View File

@@ -270,6 +270,7 @@ export const svTranslations: DefaultTranslationsObject = {
locale: 'Lokal',
locales: 'Språk',
menu: 'Meny',
moreOptions: 'Fler alternativ',
moveDown: 'Flytta Ner',
moveUp: 'Flytta Upp',
newPassword: 'Nytt Lösenord',

View File

@@ -266,6 +266,7 @@ export const thTranslations: DefaultTranslationsObject = {
locale: 'ตำแหน่งที่ตั้ง',
locales: 'ภาษา',
menu: 'เมนู',
moreOptions: 'ตัวเลือกเพิ่มเติม',
moveDown: 'ขยับขึ้น',
moveUp: 'ขยับลง',
newPassword: 'รหัสผ่านใหม่',

View File

@@ -273,6 +273,7 @@ export const trTranslations: DefaultTranslationsObject = {
locale: 'Yerel ayar',
locales: 'Diller',
menu: 'Menü',
moreOptions: 'Daha fazla seçenek',
moveDown: 'Aşağı taşı',
moveUp: 'Yukarı taşı',
newPassword: 'Yeni parola',

View File

@@ -269,6 +269,7 @@ export const ukTranslations: DefaultTranslationsObject = {
locale: 'Локаль',
locales: 'Локалі',
menu: 'Меню',
moreOptions: 'Більше варіантів',
moveDown: 'Перемістити нижче',
moveUp: 'Перемістити вище',
newPassword: 'Новий пароль',

View File

@@ -269,6 +269,7 @@ export const viTranslations: DefaultTranslationsObject = {
locale: 'Ngôn ngữ',
locales: 'Khu vực',
menu: 'Thực đơn',
moreOptions: 'Nhiều lựa chọn hơn',
moveDown: 'Di chuyển xuống',
moveUp: 'Di chuyển lên',
newPassword: 'Mật khảu mới',

View File

@@ -260,6 +260,7 @@ export const zhTranslations: DefaultTranslationsObject = {
locale: '语言环境',
locales: '语言环境',
menu: '菜单',
moreOptions: '更多选项',
moveDown: '向下移动',
moveUp: '向上移动',
newPassword: '新密码',

View File

@@ -260,6 +260,7 @@ export const zhTwTranslations: DefaultTranslationsObject = {
locale: '語言環境',
locales: '語言環境',
menu: '菜單',
moreOptions: '更多選項',
moveDown: '向下移動',
moveUp: '向上移動',
newPassword: '新密碼',

View File

@@ -19,6 +19,11 @@
}
}
&__custom-control {
padding: 0;
border-radius: 0;
}
&__buttons-wrap {
display: flex;
align-items: center;

View File

@@ -5,8 +5,10 @@ import { useWindowInfo } from '@faceless-ui/window-info'
import { getTranslation } from '@payloadcms/translations'
import React, { Fragment, useEffect, useRef, useState } from 'react'
import { Popup, PopupList } from '../../elements/Popup/index.js'
import { useUseTitleField } from '../../hooks/useUseAsTitle.js'
import { ChevronIcon } from '../../icons/Chevron/index.js'
import { Dots } from '../../icons/Dots/index.js'
import { SearchIcon } from '../../icons/Search/index.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
@@ -36,6 +38,7 @@ export type ListControlsProps = {
readonly handleSearchChange?: (search: string) => void
readonly handleSortChange?: (sort: string) => void
readonly handleWhereChange?: (where: Where) => void
readonly listMenuItems?: React.ReactNode[]
readonly renderedFilters?: Map<string, React.ReactNode>
readonly resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
}
@@ -54,10 +57,10 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
disableBulkEdit,
enableColumns = true,
enableSort = false,
listMenuItems,
renderedFilters,
resolvedFilterOptions,
} = props
const { handleSearchChange, query } = useListQuery()
const titleField = useUseTitleField(collectionConfig)
const { i18n, t } = useTranslation()
@@ -194,6 +197,17 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
{t('general:sort')}
</Pill>
)}
{listMenuItems && (
<Popup
button={<Dots ariaLabel={t('general:moreOptions')} />}
className={`${baseClass}__popup`}
horizontalAlign="right"
size="large"
verticalAlign="bottom"
>
<PopupList.ButtonGroup>{listMenuItems.map((item) => item)}</PopupList.ButtonGroup>
</Popup>
)}
</div>
</div>
</div>

View File

@@ -0,0 +1,27 @@
@import '../../scss/styles';
@layer payload-default {
.dots {
margin: 0;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 2px;
background-color: var(--theme-elevation-150);
border-radius: $style-radius-m;
height: calc(var(--base) * 1.2);
width: calc(var(--base) * 1.2);
&:hover {
background-color: var(--theme-elevation-100);
}
> div {
width: 2.5px;
height: 2.5px;
border-radius: 100%;
background-color: currentColor;
}
}
}

View File

@@ -0,0 +1,17 @@
import React from 'react'
import './index.scss'
export const Dots: React.FC<{ ariaLabel?: string; className?: string }> = ({
ariaLabel,
className,
}) => (
<div
aria-label={ariaLabel}
className={[className && className, 'dots'].filter(Boolean).join(' ')}
>
<div />
<div />
<div />
</div>
)

View File

@@ -52,6 +52,7 @@ export function DefaultListView(props: ListViewClientProps) {
disableBulkEdit,
enableRowSelections,
hasCreatePermission,
listMenuItems,
listPreferences,
newDocumentURL,
preferenceKey,
@@ -195,6 +196,7 @@ export function DefaultListView(props: ListViewClientProps) {
collectionSlug={collectionSlug}
disableBulkDelete={disableBulkDelete}
disableBulkEdit={disableBulkEdit}
listMenuItems={listMenuItems}
renderedFilters={renderedFilters}
resolvedFilterOptions={resolvedFilterOptions}
/>

View File

@@ -33,6 +33,26 @@ export const Posts: CollectionConfig = {
},
},
],
listMenuItems: [
{
path: '/components/Banner/index.js#Banner',
clientProps: {
message: 'listMenuItems',
},
},
{
path: '/components/Banner/index.js#Banner',
clientProps: {
message: 'Many of them',
},
},
{
path: '/components/Banner/index.js#Banner',
clientProps: {
message: 'Ok last one',
},
},
],
afterList: [
{
path: '/components/Banner/index.js#Banner',

View File

@@ -198,6 +198,19 @@ describe('List View', () => {
).toBeVisible()
})
test('should render custom listMenuItems component', async () => {
await page.goto(postsUrl.list)
const kebabMenu = page.locator('.list-controls__popup')
await expect(kebabMenu).toBeVisible()
await kebabMenu.click()
await expect(
page.locator('.popup-button-list').locator('div', {
hasText: 'listMenuItems',
}),
).toBeVisible()
})
test('should render custom afterListTable component', async () => {
await page.goto(postsUrl.list)
await expect(