feat: group by (#13138)

Supports grouping documents by specific fields within the list view.

For example, imagine having a "posts" collection with a "categories"
field. To report on each specific category, you'd traditionally filter
for each category, one at a time. This can be quite inefficient,
especially with large datasets.

Now, you can interact with all categories simultaneously, grouped by
distinct values.

Here is a simple demonstration:


https://github.com/user-attachments/assets/0dcd19d2-e983-47e6-9ea2-cfdd2424d8b5

Enable on any collection by setting the `admin.groupBy` property:

```ts
import type { CollectionConfig } from 'payload'

const MyCollection: CollectionConfig = {
  // ...
  admin: {
    groupBy: true
  }
}
```

This is currently marked as beta to gather feedback while we reach full
stability, and to leave room for API changes and other modifications.
Use at your own risk.

Note: when using `groupBy`, bulk editing is done group-by-group. In the
future we may support cross-group bulk editing.

Dependent on #13102 (merged).

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210774523852467

---------

Co-authored-by: Paul Popus <paul@payloadcms.com>
This commit is contained in:
Jacob Fletcher
2025-07-24 14:00:52 -04:00
committed by GitHub
parent 14322a71bb
commit bccf6ab16f
124 changed files with 7181 additions and 447 deletions

View File

@@ -284,6 +284,7 @@ jobs:
- fields__collections__Text
- fields__collections__UI
- fields__collections__Upload
- group-by
- folders
- hooks
- lexical__collections__Lexical__e2e__main
@@ -419,6 +420,7 @@ jobs:
- fields__collections__Text
- fields__collections__UI
- fields__collections__Upload
- group-by
- folders
- hooks
- lexical__collections__Lexical__e2e__main

View File

@@ -77,7 +77,7 @@ All auto-generated files will contain the following comments at the top of each
## Admin Options
All options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property:
All root-level options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property:
```ts
import { buildConfig } from 'payload'

View File

@@ -130,6 +130,7 @@ The following options are available:
| `description` | Text to display below the Collection label in the List View to give editors more information. Alternatively, you can use the `admin.components.Description` to render a React component. [More details](#custom-components). |
| `defaultColumns` | Array of field names that correspond to which columns to show by default in this Collection's List View. |
| `disableCopyToLocale` | Disables the "Copy to Locale" button while editing documents within this Collection. Only applicable when localization is enabled. |
| `groupBy` | Beta. Enable grouping by a field in the list view. |
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this Collection. |
| `enableRichTextLink` | The [Rich Text](../fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `enableRichTextRelationship` | The [Rich Text](../fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |

View File

@@ -0,0 +1,199 @@
import type {
ClientConfig,
Column,
ListQuery,
PaginatedDocs,
PayloadRequest,
SanitizedCollectionConfig,
Where,
} from 'payload'
import { renderTable } from '@payloadcms/ui/rsc'
import { formatDate } from '@payloadcms/ui/shared'
import { flattenAllFields } from 'payload'
export const handleGroupBy = async ({
clientConfig,
collectionConfig,
collectionSlug,
columns,
customCellProps,
drawerSlug,
enableRowSelections,
query,
req,
user,
where: whereWithMergedSearch,
}: {
clientConfig: ClientConfig
collectionConfig: SanitizedCollectionConfig
collectionSlug: string
columns: any[]
customCellProps?: Record<string, any>
drawerSlug?: string
enableRowSelections?: boolean
query?: ListQuery
req: PayloadRequest
user: any
where: Where
}): Promise<{
columnState: Column[]
data: PaginatedDocs
Table: null | React.ReactNode | React.ReactNode[]
}> => {
let Table: React.ReactNode | React.ReactNode[] = null
let columnState: Column[]
const dataByGroup: Record<string, PaginatedDocs> = {}
const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug)
// NOTE: is there a faster/better way to do this?
const flattenedFields = flattenAllFields({ fields: collectionConfig.fields })
const groupByFieldPath = query.groupBy.replace(/^-/, '')
const groupByField = flattenedFields.find((f) => f.name === groupByFieldPath)
const relationshipConfig =
groupByField?.type === 'relationship'
? clientConfig.collections.find((c) => c.slug === groupByField.relationTo)
: undefined
let populate
if (groupByField?.type === 'relationship' && groupByField.relationTo) {
const relationTo =
typeof groupByField.relationTo === 'string'
? [groupByField.relationTo]
: groupByField.relationTo
if (Array.isArray(relationTo)) {
relationTo.forEach((rel) => {
if (!populate) {
populate = {}
}
populate[rel] = { [relationshipConfig?.admin.useAsTitle || 'id']: true }
})
}
}
const distinct = await req.payload.findDistinct({
collection: collectionSlug,
depth: 1,
field: groupByFieldPath,
limit: query?.limit ? Number(query.limit) : undefined,
locale: req.locale,
overrideAccess: false,
page: query?.page ? Number(query.page) : undefined,
populate,
req,
sort: query?.groupBy,
where: whereWithMergedSearch,
})
const data = {
...distinct,
docs: distinct.values?.map(() => ({})) || [],
values: undefined,
}
await Promise.all(
distinct.values.map(async (distinctValue, i) => {
const potentiallyPopulatedRelationship = distinctValue[groupByFieldPath]
const valueOrRelationshipID =
groupByField?.type === 'relationship' &&
potentiallyPopulatedRelationship &&
typeof potentiallyPopulatedRelationship === 'object' &&
'id' in potentiallyPopulatedRelationship
? potentiallyPopulatedRelationship.id
: potentiallyPopulatedRelationship
const groupData = await req.payload.find({
collection: collectionSlug,
depth: 0,
draft: true,
fallbackLocale: false,
includeLockStatus: true,
limit: query?.queryByGroup?.[valueOrRelationshipID]?.limit
? Number(query.queryByGroup[valueOrRelationshipID].limit)
: undefined,
locale: req.locale,
overrideAccess: false,
page: query?.queryByGroup?.[valueOrRelationshipID]?.page
? Number(query.queryByGroup[valueOrRelationshipID].page)
: undefined,
req,
// Note: if we wanted to enable table-by-table sorting, we could use this:
// sort: query?.queryByGroup?.[valueOrRelationshipID]?.sort,
sort: query?.sort,
user,
where: {
...(whereWithMergedSearch || {}),
[groupByFieldPath]: {
equals: valueOrRelationshipID,
},
},
})
let heading = valueOrRelationshipID || req.i18n.t('general:noValue')
if (
groupByField?.type === 'relationship' &&
typeof potentiallyPopulatedRelationship === 'object'
) {
heading =
potentiallyPopulatedRelationship[relationshipConfig.admin.useAsTitle || 'id'] ||
valueOrRelationshipID
}
if (groupByField.type === 'date') {
heading = formatDate({
date: String(heading),
i18n: req.i18n,
pattern: clientConfig.admin.dateFormat,
})
}
if (groupData.docs && groupData.docs.length > 0) {
const { columnState: newColumnState, Table: NewTable } = renderTable({
clientCollectionConfig,
collectionConfig,
columns,
customCellProps,
data: groupData,
drawerSlug,
enableRowSelections,
groupByFieldPath,
groupByValue: valueOrRelationshipID,
heading,
i18n: req.i18n,
key: `table-${valueOrRelationshipID}`,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload: req.payload,
query,
useAsTitle: collectionConfig.admin.useAsTitle,
})
// Only need to set `columnState` once, using the first table's column state
// This will avoid needing to generate column state explicitly for root context that wraps all tables
if (!columnState) {
columnState = newColumnState
}
if (!Table) {
Table = []
}
dataByGroup[valueOrRelationshipID] = groupData
;(Table as Array<React.ReactNode>)[i] = NewTable
}
}),
)
return {
columnState,
data,
Table,
}
}

View File

@@ -1,10 +1,12 @@
import type {
AdminViewServerProps,
CollectionPreferences,
Column,
ColumnPreference,
ListQuery,
ListViewClientProps,
ListViewServerPropsOnly,
PaginatedDocs,
QueryPreset,
SanitizedCollectionPermission,
} from 'payload'
@@ -24,6 +26,7 @@ import {
import React, { Fragment } from 'react'
import { getDocumentPermissions } from '../Document/getDocumentPermissions.js'
import { handleGroupBy } from './handleGroupBy.js'
import { renderListViewSlots } from './renderListViewSlots.js'
import { resolveAllFilterOptions } from './resolveAllFilterOptions.js'
@@ -74,7 +77,6 @@ export const renderListView = async (
req,
req: {
i18n,
locale,
payload,
payload: { config },
query: queryFromReq,
@@ -91,11 +93,17 @@ export const renderListView = async (
const columnsFromQuery: ColumnPreference[] = transformColumnsToPreferences(query?.columns)
query.queryByGroup =
query?.queryByGroup && typeof query.queryByGroup === 'string'
? JSON.parse(query.queryByGroup)
: query?.queryByGroup
const collectionPreferences = await upsertPreferences<CollectionPreferences>({
key: `collection-${collectionSlug}`,
req,
value: {
columns: columnsFromQuery,
groupBy: query?.groupBy,
limit: isNumber(query?.limit) ? Number(query.limit) : undefined,
preset: query?.preset,
sort: query?.sort as string,
@@ -112,6 +120,8 @@ export const renderListView = async (
collectionPreferences?.sort ||
(typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined)
query.groupBy = collectionPreferences?.groupBy
query.columns = transformColumnsToSearchParams(collectionPreferences?.columns || [])
const {
@@ -137,6 +147,12 @@ export const renderListView = async (
let queryPreset: QueryPreset | undefined
let queryPresetPermissions: SanitizedCollectionPermission | undefined
const whereWithMergedSearch = mergeListSearchAndWhere({
collectionConfig,
search: typeof query?.search === 'string' ? query.search : undefined,
where: combineWhereConstraints([query?.where, baseListFilter]),
})
if (collectionPreferences?.preset) {
try {
queryPreset = (await payload.findByID({
@@ -160,41 +176,55 @@ export const renderListView = async (
}
}
const data = await payload.find({
collection: collectionSlug,
depth: 0,
draft: true,
fallbackLocale: false,
includeLockStatus: true,
limit: query.limit,
locale,
overrideAccess: false,
page: query.page,
req,
sort: query.sort,
user,
where: mergeListSearchAndWhere({
let data: PaginatedDocs | undefined
let Table: React.ReactNode | React.ReactNode[] = null
let columnState: Column[] = []
if (collectionConfig.admin.groupBy && query.groupBy) {
;({ columnState, data, Table } = await handleGroupBy({
clientConfig,
collectionConfig,
search: typeof query?.search === 'string' ? query.search : undefined,
where: combineWhereConstraints([query?.where, baseListFilter]),
}),
})
const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug)
const { columnState, Table } = renderTable({
clientCollectionConfig,
collectionConfig,
columns: collectionPreferences?.columns,
customCellProps,
docs: data.docs,
drawerSlug,
enableRowSelections,
i18n: req.i18n,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload,
useAsTitle: collectionConfig.admin.useAsTitle,
})
collectionSlug,
columns: collectionPreferences?.columns,
customCellProps,
drawerSlug,
enableRowSelections,
query,
req,
user,
where: whereWithMergedSearch,
}))
} else {
data = await req.payload.find({
collection: collectionSlug,
depth: 0,
draft: true,
fallbackLocale: false,
includeLockStatus: true,
limit: query?.limit ? Number(query.limit) : undefined,
locale: req.locale,
overrideAccess: false,
page: query?.page ? Number(query.page) : undefined,
req,
sort: query?.sort,
user,
where: whereWithMergedSearch,
})
;({ columnState, Table } = renderTable({
clientCollectionConfig: clientConfig.collections.find((c) => c.slug === collectionSlug),
collectionConfig,
columns: collectionPreferences?.columns,
customCellProps,
data,
drawerSlug,
enableRowSelections,
i18n: req.i18n,
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
payload: req.payload,
query,
useAsTitle: collectionConfig.admin.useAsTitle,
}))
}
const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap)
@@ -249,6 +279,7 @@ export const renderListView = async (
const isInDrawer = Boolean(drawerSlug)
// Needed to prevent: Only plain objects can be passed to Client Components from Server Components. Objects with toJSON methods are not supported. Convert it manually to a simple value before passing it to props.
// Is there a way to avoid this? The `where` object is already seemingly plain, but is not bc it originates from the params.
query.where = query?.where ? JSON.parse(JSON.stringify(query?.where || {})) : undefined
return {

View File

@@ -45,9 +45,15 @@ export type ListQuery = {
* Use `transformColumnsToPreferences` and `transformColumnsToSearchParams` to convert it back and forth
*/
columns?: ColumnsFromURL
/*
* A string representing the field to group by, e.g. `category`
* A leading hyphen represents descending order, e.g. `-category`
*/
groupBy?: string
limit?: number
page?: number
preset?: number | string
queryByGroup?: Record<string, ListQuery>
/*
When provided, is automatically injected into the `where` object
*/
@@ -59,6 +65,10 @@ export type ListQuery = {
export type BuildTableStateArgs = {
collectionSlug: string | string[]
columns?: ColumnPreference[]
data?: PaginatedDocs
/**
* @deprecated Use `data` instead
*/
docs?: PaginatedDocs['docs']
enableRowSelections?: boolean
orderableFieldName: string

View File

@@ -17,7 +17,7 @@ export type ListViewSlots = {
BeforeListTable?: React.ReactNode
Description?: React.ReactNode
listMenuItems?: React.ReactNode[]
Table: React.ReactNode
Table: React.ReactNode | React.ReactNode[]
}
/**

View File

@@ -367,6 +367,13 @@ export type CollectionAdminOptions = {
* - Set to `false` to exclude the entity from the sidebar / dashboard without disabling its routes.
*/
group?: false | Record<string, string> | string
/**
* @experimental This option is currently in beta and may change in future releases and/or contain bugs.
* Use at your own risk.
* @description Enable grouping by a field in the list view.
* Uses `payload.findDistinct` under the hood to populate the group-by options.
*/
groupBy?: boolean
/**
* Exclude the collection from the admin nav and routes
*/

View File

@@ -1208,7 +1208,6 @@ export { findVersionsOperation } from './collections/operations/findVersions.js'
export { restoreVersionOperation } from './collections/operations/restoreVersion.js'
export { updateOperation } from './collections/operations/update.js'
export { updateByIDOperation } from './collections/operations/updateByID.js'
export { buildConfig } from './config/build.js'
export {
type ClientConfig,

View File

@@ -37,6 +37,7 @@ export type ColumnPreference = {
export type CollectionPreferences = {
columns?: ColumnPreference[]
editViewType?: 'default' | 'live-preview'
groupBy?: string
limit?: number
preset?: DefaultDocumentIDType
sort?: string

View File

@@ -13,6 +13,10 @@ export type ColumnsFromURL = string[]
export const transformColumnsToPreferences = (
columns: Column[] | ColumnPreference[] | ColumnsFromURL | string | undefined,
): ColumnPreference[] | undefined => {
if (!columns) {
return undefined
}
let columnsToTransform = columns
// Columns that originate from the URL are a stringified JSON array and need to be parsed first

View File

@@ -185,6 +185,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
'general:confirmReindexDescription',
'general:confirmReindexDescriptionAll',
'general:copied',
'general:clear',
'general:clearAll',
'general:copy',
'general:copyField',
@@ -232,6 +233,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
'general:filterWhere',
'general:globals',
'general:goBack',
'general:groupByLabel',
'general:isEditing',
'general:item',
'general:items',

View File

@@ -224,6 +224,7 @@ export const arTranslations: DefaultTranslationsObject = {
backToDashboard: 'العودة للوحة التّحكّم',
cancel: 'إلغاء',
changesNotSaved: 'لم يتمّ حفظ التّغييرات. إن غادرت الآن ، ستفقد تغييراتك.',
clear: 'واضح',
clearAll: 'امسح الكل',
close: 'إغلاق',
collapse: 'طيّ',
@@ -294,6 +295,7 @@ export const arTranslations: DefaultTranslationsObject = {
filterWhere: 'تصفية {{label}} حيث',
globals: 'عامة',
goBack: 'العودة',
groupByLabel: 'التجميع حسب {{label}}',
import: 'استيراد',
isEditing: 'يحرر',
item: 'عنصر',

View File

@@ -229,6 +229,8 @@ export const azTranslations: DefaultTranslationsObject = {
cancel: 'Ləğv et',
changesNotSaved:
'Dəyişiklikləriniz saxlanılmayıb. İndi çıxsanız, dəyişikliklərinizi itirəcəksiniz.',
clear:
'Payload kontekstində orijinal mətnin mənasını qoruya. İşte Payload terminləri siyahısıdır ki, onlar üzərində çox xüsusi mənalar gəlir:\n - Kolleksiya: Kolleksiya sənədlərin hamıya ortaq struktur və məqsəd sərbəst olan bir qrupdur. Kolleksiyalar Payload-da məzmunu təşkil etmək və idarə etmək üçün istifadə edilir.\n - Sahə: Sahə',
clearAll: 'Hamısını təmizlə',
close: 'Bağla',
collapse: 'Bağla',
@@ -300,6 +302,7 @@ export const azTranslations: DefaultTranslationsObject = {
filterWhere: '{{label}} filtrlə',
globals: 'Qloballar',
goBack: 'Geri qayıt',
groupByLabel: '{{label}} ilə qruplaşdırın',
import: 'İdxal',
isEditing: 'redaktə edir',
item: 'əşya',

View File

@@ -229,6 +229,7 @@ export const bgTranslations: DefaultTranslationsObject = {
backToDashboard: 'Обратно към таблото',
cancel: 'Отмени',
changesNotSaved: 'Промените ти не са запазени. Ако напуснеш сега, ще ги загубиш.',
clear: 'Ясно',
clearAll: 'Изчисти всичко',
close: 'Затвори',
collapse: 'Свий',
@@ -300,6 +301,7 @@ export const bgTranslations: DefaultTranslationsObject = {
filterWhere: 'Филтрирай {{label}} където',
globals: 'Глобални',
goBack: 'Върни се',
groupByLabel: 'Групирай по {{label}}',
import: 'Внос',
isEditing: 'редактира',
item: 'артикул',

View File

@@ -231,6 +231,8 @@ export const bnBdTranslations: DefaultTranslationsObject = {
cancel: 'বাতিল করুন',
changesNotSaved:
'আপনার পরিবর্তনগুলি সংরক্ষণ করা হয়নি। আপনি যদি এখন চলে যান, তাহলে আপনার পরিবর্তনগুলি হারিয়ে যাবে।',
clear:
'মূল পাঠের অর্থ সম্মান করুন পেলোড প্রসঙ্গে। এখানে পেলোড নির্দিষ্ট বিশেষ অর্থ বহন করে এরকম একটি সাধারণ টার্মের তালিকা:\n - সংগ্রহ',
clearAll: 'সমস্ত সাফ করুন',
close: 'বন্ধ করুন',
collapse: 'সংকুচিত করুন',
@@ -302,6 +304,7 @@ export const bnBdTranslations: DefaultTranslationsObject = {
filterWhere: '{{label}} যেখানে ফিল্টার করুন',
globals: 'গ্লোবালগুলি',
goBack: 'পিছনে যান',
groupByLabel: '{{label}} অনুযায়ী গ্রুপ করুন',
import: 'ইম্পোর্ট করুন',
isEditing: 'সম্পাদনা করছেন',
item: 'আইটেম',

View File

@@ -231,6 +231,7 @@ export const bnInTranslations: DefaultTranslationsObject = {
cancel: 'বাতিল করুন',
changesNotSaved:
'আপনার পরিবর্তনগুলি সংরক্ষণ করা হয়নি। আপনি যদি এখন চলে যান, তাহলে আপনার পরিবর্তনগুলি হারিয়ে যাবে।',
clear: 'স্পষ্ট',
clearAll: 'সমস্ত সাফ করুন',
close: 'বন্ধ করুন',
collapse: 'সংকুচিত করুন',
@@ -302,6 +303,7 @@ export const bnInTranslations: DefaultTranslationsObject = {
filterWhere: '{{label}} যেখানে ফিল্টার করুন',
globals: 'গ্লোবালগুলি',
goBack: 'পিছনে যান',
groupByLabel: '{{label}} দ্বারা গ্রুপ করুন',
import: 'ইম্পোর্ট করুন',
isEditing: 'সম্পাদনা করছেন',
item: 'আইটেম',

View File

@@ -230,6 +230,7 @@ export const caTranslations: DefaultTranslationsObject = {
backToDashboard: 'Torna al tauler',
cancel: 'Cancel·la',
changesNotSaved: 'El teu document té canvis no desats. Si continues, els canvis es perdran.',
clear: 'Clar',
clearAll: 'Esborra-ho tot',
close: 'Tanca',
collapse: 'Replegar',
@@ -301,6 +302,7 @@ export const caTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtra {{label}} on',
globals: 'Globals',
goBack: 'Torna enrere',
groupByLabel: 'Agrupa per {{label}}',
import: 'Importar',
isEditing: 'esta editant',
item: 'element',

View File

@@ -229,6 +229,7 @@ export const csTranslations: DefaultTranslationsObject = {
backToDashboard: 'Zpět na nástěnku',
cancel: 'Zrušit',
changesNotSaved: 'Vaše změny nebyly uloženy. Pokud teď odejdete, ztratíte své změny.',
clear: 'Jasný',
clearAll: 'Vymazat vše',
close: 'Zavřít',
collapse: 'Sbalit',
@@ -299,6 +300,7 @@ export const csTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtrovat {{label}} kde',
globals: 'Globální',
goBack: 'Vrátit se',
groupByLabel: 'Seskupit podle {{label}}',
import: 'Import',
isEditing: 'upravuje',
item: 'položka',

View File

@@ -228,6 +228,7 @@ export const daTranslations: DefaultTranslationsObject = {
cancel: 'Anuller',
changesNotSaved:
'Dine ændringer er ikke blevet gemt. Hvis du forlader siden, vil din ændringer gå tabt.',
clear: 'Klar',
clearAll: 'Ryd alt',
close: 'Luk',
collapse: 'Skjul',
@@ -298,6 +299,7 @@ export const daTranslations: DefaultTranslationsObject = {
filterWhere: 'Filter {{label}} hvor',
globals: 'Globale',
goBack: 'Gå tilbage',
groupByLabel: 'Gruppér efter {{label}}',
import: 'Import',
isEditing: 'redigerer',
item: 'vare',

View File

@@ -235,6 +235,8 @@ export const deTranslations: DefaultTranslationsObject = {
cancel: 'Abbrechen',
changesNotSaved:
'Deine Änderungen wurden nicht gespeichert. Wenn du diese Seite verlässt, gehen deine Änderungen verloren.',
clear:
'Respektieren Sie die Bedeutung des ursprünglichen Textes im Kontext von Payload. Hier ist eine Liste von gängigen Payload-Begriffen, die sehr spezifische Bedeutungen tragen:\n - Sammlung: Eine Sammlung ist eine Gruppe von Dokumenten, die eine gemeinsame Struktur und Funktion teilen. Sammlungen werden verwendet, um Inhalte in Payload zu organisieren und zu verwalten.\n - Feld: Ein Feld ist ein spezifisches Datenstück innerhalb eines Dokuments in einer Sammlung. Felder definieren die Struktur und den Datentyp, der in einem Dokument gespeichert werden kann.\n -',
clearAll: 'Alles löschen',
close: 'Schließen',
collapse: 'Einklappen',
@@ -306,6 +308,7 @@ export const deTranslations: DefaultTranslationsObject = {
filterWhere: 'Filter {{label}}, wo',
globals: 'Globale Dokumente',
goBack: 'Zurück',
groupByLabel: 'Gruppieren nach {{label}}',
import: 'Importieren',
isEditing: 'bearbeitet gerade',
item: 'Artikel',

View File

@@ -230,6 +230,7 @@ export const enTranslations = {
cancel: 'Cancel',
changesNotSaved:
'Your changes have not been saved. If you leave now, you will lose your changes.',
clear: 'Clear',
clearAll: 'Clear All',
close: 'Close',
collapse: 'Collapse',
@@ -301,6 +302,7 @@ export const enTranslations = {
filterWhere: 'Filter {{label}} where',
globals: 'Globals',
goBack: 'Go back',
groupByLabel: 'Group by {{label}}',
import: 'Import',
isEditing: 'is editing',
item: 'item',

View File

@@ -234,6 +234,7 @@ export const esTranslations: DefaultTranslationsObject = {
cancel: 'Cancelar',
changesNotSaved:
'Tus cambios no han sido guardados. Si te sales ahora, se perderán tus cambios.',
clear: 'Claro',
clearAll: 'Limpiar todo',
close: 'Cerrar',
collapse: 'Contraer',
@@ -305,6 +306,7 @@ export const esTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtrar {{label}} donde',
globals: 'Globales',
goBack: 'Volver',
groupByLabel: 'Agrupar por {{label}}',
import: 'Importar',
isEditing: 'está editando',
item: 'artículo',

View File

@@ -227,6 +227,7 @@ export const etTranslations: DefaultTranslationsObject = {
backToDashboard: 'Tagasi töölaua juurde',
cancel: 'Tühista',
changesNotSaved: 'Teie muudatusi pole salvestatud. Kui lahkute praegu, kaotate oma muudatused.',
clear: 'Selge',
clearAll: 'Tühjenda kõik',
close: 'Sulge',
collapse: 'Ahenda',
@@ -297,6 +298,7 @@ export const etTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtreeri {{label}} kus',
globals: 'Globaalsed',
goBack: 'Mine tagasi',
groupByLabel: 'Rühmita {{label}} järgi',
import: 'Importimine',
isEditing: 'muudab',
item: 'üksus',

View File

@@ -227,6 +227,7 @@ export const faTranslations: DefaultTranslationsObject = {
cancel: 'لغو',
changesNotSaved:
'تغییرات شما ذخیره نشده، اگر این برگه را ترک کنید. تمام تغییرات از دست خواهد رفت.',
clear: 'روشن',
clearAll: 'همه را پاک کنید',
close: 'بستن',
collapse: 'بستن',
@@ -298,6 +299,7 @@ export const faTranslations: DefaultTranslationsObject = {
filterWhere: 'علامت گذاری کردن {{label}} جایی که',
globals: 'سراسری',
goBack: 'برگشت',
groupByLabel: 'گروه بندی بر اساس {{label}}',
import: 'واردات',
isEditing: 'در حال ویرایش است',
item: 'مورد',

View File

@@ -237,6 +237,7 @@ export const frTranslations: DefaultTranslationsObject = {
cancel: 'Annuler',
changesNotSaved:
'Vos modifications nont pas été enregistrées. Vous perdrez vos modifications si vous quittez maintenant.',
clear: 'Clair',
clearAll: 'Tout effacer',
close: 'Fermer',
collapse: 'Réduire',
@@ -308,6 +309,7 @@ export const frTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtrer {{label}} où',
globals: 'Globals(es)',
goBack: 'Retourner',
groupByLabel: 'Regrouper par {{label}}',
import: 'Importation',
isEditing: 'est en train de modifier',
item: 'article',

View File

@@ -222,6 +222,8 @@ export const heTranslations: DefaultTranslationsObject = {
backToDashboard: 'חזרה ללוח המחוונים',
cancel: 'ביטול',
changesNotSaved: 'השינויים שלך לא נשמרו. אם תצא כעת, תאבד את השינויים שלך.',
clear:
'בהתחשב במשמעות של הטקסט המקורי בהקשר של Payload. הנה רשימה של מונחים מקוריים של Payload שנושאים משמעויות מסוימות:\n- אוסף: אוסף הוא קבוצה של מסמכים ששותפים למבנה ולמטרה משות',
clearAll: 'נקה הכל',
close: 'סגור',
collapse: 'כווץ',
@@ -292,6 +294,7 @@ export const heTranslations: DefaultTranslationsObject = {
filterWhere: 'סנן {{label}} בהם',
globals: 'גלובלים',
goBack: 'חזור',
groupByLabel: 'קבץ לפי {{label}}',
import: 'יבוא',
isEditing: 'עורך',
item: 'פריט',

View File

@@ -230,6 +230,7 @@ export const hrTranslations: DefaultTranslationsObject = {
backToDashboard: 'Natrag na nadzornu ploču',
cancel: 'Otkaži',
changesNotSaved: 'Vaše promjene nisu spremljene. Ako izađete sada, izgubit ćete promjene.',
clear: 'Jasan',
clearAll: 'Očisti sve',
close: 'Zatvori',
collapse: 'Sažmi',
@@ -301,6 +302,7 @@ export const hrTranslations: DefaultTranslationsObject = {
filterWhere: 'Filter {{label}} gdje',
globals: 'Globali',
goBack: 'Vrati se',
groupByLabel: 'Grupiraj po {{label}}',
import: 'Uvoz',
isEditing: 'uređuje',
item: 'stavka',

View File

@@ -232,6 +232,7 @@ export const huTranslations: DefaultTranslationsObject = {
cancel: 'Mégsem',
changesNotSaved:
'A módosítások nem lettek mentve. Ha most távozik, elveszíti a változtatásokat.',
clear: 'Tiszta',
clearAll: 'Törölj mindent',
close: 'Bezárás',
collapse: 'Összecsukás',
@@ -303,6 +304,7 @@ export const huTranslations: DefaultTranslationsObject = {
filterWhere: 'Szűrő {{label}} ahol',
globals: 'Globálisok',
goBack: 'Vissza',
groupByLabel: 'Csoportosítás {{label}} szerint',
import: 'Behozatal',
isEditing: 'szerkeszt',
item: 'tétel',

View File

@@ -230,6 +230,8 @@ export const hyTranslations: DefaultTranslationsObject = {
cancel: 'Չեղարկել',
changesNotSaved:
'Ձեր փոփոխությունները չեն պահպանվել։ Եթե հիմա հեռանաք, կկորցնեք չպահպանված փոփոխությունները։',
clear:
'Հիմնական տեքստի իմաստը պետք է պահպանվի Payload կոնտեքստի մեջ: Այս այս այստեղ են հաճախակի',
clearAll: 'Մաքրել բոլորը',
close: 'Փակել',
collapse: 'Փակել',
@@ -301,6 +303,7 @@ export const hyTranslations: DefaultTranslationsObject = {
filterWhere: 'Ֆիլտրել {{label}}-ը, որտեղ',
globals: 'Համընդհանուրներ',
goBack: 'Հետ գնալ',
groupByLabel: 'Խմբավորել {{label}}-ով',
import: 'Ներմուծում',
isEditing: 'խմբագրում է',
item: 'տարր',

View File

@@ -234,6 +234,7 @@ export const itTranslations: DefaultTranslationsObject = {
backToDashboard: 'Torna alla Dashboard',
cancel: 'Cancella',
changesNotSaved: 'Le tue modifiche non sono state salvate. Se esci ora, verranno perse.',
clear: 'Chiara',
clearAll: 'Cancella Tutto',
close: 'Chiudere',
collapse: 'Comprimi',
@@ -304,6 +305,7 @@ export const itTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtra {{label}} se',
globals: 'Globali',
goBack: 'Torna indietro',
groupByLabel: 'Raggruppa per {{label}}',
import: 'Importare',
isEditing: 'sta modificando',
item: 'articolo',

View File

@@ -230,6 +230,7 @@ export const jaTranslations: DefaultTranslationsObject = {
backToDashboard: 'ダッシュボードに戻る',
cancel: 'キャンセル',
changesNotSaved: '未保存の変更があります。このまま画面を離れると内容が失われます。',
clear: 'クリア',
clearAll: 'すべてクリア',
close: '閉じる',
collapse: '閉じる',
@@ -301,6 +302,7 @@ export const jaTranslations: DefaultTranslationsObject = {
filterWhere: '{{label}} の絞り込み',
globals: 'グローバル',
goBack: '戻る',
groupByLabel: '{{label}}でグループ化する',
import: '輸入',
isEditing: '編集中',
item: 'アイテム',

View File

@@ -227,6 +227,8 @@ export const koTranslations: DefaultTranslationsObject = {
backToDashboard: '대시보드로 돌아가기',
cancel: '취소',
changesNotSaved: '변경 사항이 저장되지 않았습니다. 지금 떠나면 변경 사항을 잃게 됩니다.',
clear:
'페이로드의 맥락 내에서 원문의 의미를 존중하십시오. 다음은 페이로드에서 사용되는 특정 의미를 내포하는 일반적인 페이로드 용어 목록입니다: \n- Collection: 컬렉션은 공통의 구조와 목적을 공유하는 문서의 그룹입니다. 컬렉션은 페이로드에서 콘텐츠를 정리하고 관리하는 데 사용됩니다.\n- Field: 필드는 컬렉',
clearAll: '모두 지우기',
close: '닫기',
collapse: '접기',
@@ -297,6 +299,7 @@ export const koTranslations: DefaultTranslationsObject = {
filterWhere: '{{label}} 필터링 조건',
globals: '글로벌',
goBack: '돌아가기',
groupByLabel: '{{label}}로 그룹화',
import: '수입',
isEditing: '편집 중',
item: '항목',

View File

@@ -232,6 +232,7 @@ export const ltTranslations: DefaultTranslationsObject = {
cancel: 'Atšaukti',
changesNotSaved:
'Jūsų pakeitimai nebuvo išsaugoti. Jei dabar išeisite, prarasite savo pakeitimus.',
clear: 'Aišku',
clearAll: 'Išvalyti viską',
close: 'Uždaryti',
collapse: 'Susikolimas',
@@ -303,6 +304,7 @@ export const ltTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtruoti {{label}}, kur',
globals: 'Globalai',
goBack: 'Grįžkite',
groupByLabel: 'Grupuoti pagal {{label}}',
import: 'Importas',
isEditing: 'redaguoja',
item: 'daiktas',

View File

@@ -229,6 +229,8 @@ export const lvTranslations: DefaultTranslationsObject = {
backToDashboard: 'Atpakaļ uz paneli',
cancel: 'Atcelt',
changesNotSaved: 'Jūsu izmaiņas nav saglabātas. Ja tagad pametīsiet, izmaiņas tiks zaudētas.',
clear:
'Izpratiet oriģinālteksta nozīmi Payload kontekstā. Šeit ir saraksts ar Payload terminiem, kas ir ļoti specifiskas nozīmes:\n - Kolekcija: Kolekcija ir dokumentu grupa, kuriem ir kopīga struktūra un mērķis. Kolekcijas tiek izmantotas saturu organizēšanai un pārvaldīšanai Payload.\n - Lauks: Lauks ir konkrēts datu fragments dokumentā iekš kolekcijas. Lauki definē struktūru un dat',
clearAll: 'Notīrīt visu',
close: 'Aizvērt',
collapse: 'Sakļaut',
@@ -300,6 +302,7 @@ export const lvTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtrēt {{label}} kur',
globals: 'Globālie',
goBack: 'Doties atpakaļ',
groupByLabel: 'Grupēt pēc {{label}}',
import: 'Imports',
isEditing: 'redzē',
item: 'vienība',

View File

@@ -231,6 +231,7 @@ export const myTranslations: DefaultTranslationsObject = {
cancel: 'မလုပ်တော့ပါ။',
changesNotSaved:
'သင်၏ပြောင်းလဲမှုများကို မသိမ်းဆည်းရသေးပါ။ ယခု စာမျက်နှာက ထွက်လိုက်ပါက သင်၏ပြောင်းလဲမှုများ အကုန် ဆုံးရှုံးသွားပါမည်။ အကုန်နော်။',
clear: 'Jelas',
clearAll: 'အားလုံးကိုရှင်းလင်းပါ',
close: 'ပိတ်',
collapse: 'ခေါက်သိမ်းပါ။',
@@ -302,6 +303,7 @@ export const myTranslations: DefaultTranslationsObject = {
filterWhere: 'နေရာတွင် စစ်ထုတ်ပါ။',
globals: 'Globals',
goBack: 'နောက်သို့',
groupByLabel: 'Berkumpulkan mengikut {{label}}',
import: 'သွင်းကုန်',
isEditing: 'ပြင်ဆင်နေသည်',
item: 'barang',

View File

@@ -229,6 +229,7 @@ export const nbTranslations: DefaultTranslationsObject = {
cancel: 'Avbryt',
changesNotSaved:
'Endringene dine er ikke lagret. Hvis du forlater nå, vil du miste endringene dine.',
clear: 'Tydelig',
clearAll: 'Tøm alt',
close: 'Lukk',
collapse: 'Skjul',
@@ -300,6 +301,7 @@ export const nbTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtrer {{label}} der',
globals: 'Globale variabler',
goBack: 'Gå tilbake',
groupByLabel: 'Grupper etter {{label}}',
import: 'Import',
isEditing: 'redigerer',
item: 'vare',

View File

@@ -233,6 +233,7 @@ export const nlTranslations: DefaultTranslationsObject = {
cancel: 'Annuleren',
changesNotSaved:
'Uw wijzigingen zijn niet bewaard. Als u weggaat zullen de wijzigingen verloren gaan.',
clear: 'Duidelijk',
clearAll: 'Alles wissen',
close: 'Dichtbij',
collapse: 'Samenvouwen',
@@ -304,6 +305,7 @@ export const nlTranslations: DefaultTranslationsObject = {
filterWhere: 'Filter {{label}} waar',
globals: 'Globalen',
goBack: 'Ga terug',
groupByLabel: 'Groepeer op {{label}}',
import: 'Importeren',
isEditing: 'is aan het bewerken',
item: 'artikel',

View File

@@ -229,6 +229,7 @@ export const plTranslations: DefaultTranslationsObject = {
cancel: 'Anuluj',
changesNotSaved:
'Twoje zmiany nie zostały zapisane. Jeśli teraz wyjdziesz, stracisz swoje zmiany.',
clear: 'Jasne',
clearAll: 'Wyczyść wszystko',
close: 'Zamknij',
collapse: 'Zwiń',
@@ -300,6 +301,7 @@ export const plTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtruj gdzie',
globals: 'Globalne',
goBack: 'Wróć',
groupByLabel: 'Grupuj według {{label}}',
import: 'Import',
isEditing: 'edytuje',
item: 'przedmiot',

View File

@@ -230,6 +230,7 @@ export const ptTranslations: DefaultTranslationsObject = {
cancel: 'Cancelar',
changesNotSaved:
'Suas alterações não foram salvas. Se você sair agora, essas alterações serão perdidas.',
clear: 'Claro',
clearAll: 'Limpar Tudo',
close: 'Fechar',
collapse: 'Recolher',
@@ -301,6 +302,7 @@ export const ptTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtrar {{label}} em que',
globals: 'Globais',
goBack: 'Voltar',
groupByLabel: 'Agrupar por {{label}}',
import: 'Importar',
isEditing: 'está editando',
item: 'item',

View File

@@ -234,6 +234,7 @@ export const roTranslations: DefaultTranslationsObject = {
cancel: 'Anulați',
changesNotSaved:
'Modificările dvs. nu au fost salvate. Dacă plecați acum, vă veți pierde modificările.',
clear: 'Clar',
clearAll: 'Șterge tot',
close: 'Închide',
collapse: 'Colaps',
@@ -305,6 +306,7 @@ export const roTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtrează {{label}} unde',
globals: 'Globale',
goBack: 'Înapoi',
groupByLabel: 'Grupare după {{label}}',
import: 'Import',
isEditing: 'editează',
item: 'articol',

View File

@@ -230,6 +230,7 @@ export const rsTranslations: DefaultTranslationsObject = {
backToDashboard: 'Назад на контролни панел',
cancel: 'Откажи',
changesNotSaved: 'Ваше промене нису сачуване. Ако изађете сада, изгубићете промене.',
clear: 'Jasno',
clearAll: 'Obriši sve',
close: 'Затвори',
collapse: 'Скупи',
@@ -301,6 +302,7 @@ export const rsTranslations: DefaultTranslationsObject = {
filterWhere: 'Филтер {{label}} где',
globals: 'Глобали',
goBack: 'Врати се',
groupByLabel: 'Grupiši po {{label}}',
import: 'Uvoz',
isEditing: 'уређује',
item: 'artikal',

View File

@@ -230,6 +230,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = {
backToDashboard: 'Nazad na kontrolni panel',
cancel: 'Otkaži',
changesNotSaved: 'Vaše promene nisu sačuvane. Ako izađete sada, izgubićete promene.',
clear: 'Jasno',
clearAll: 'Očisti sve',
close: 'Zatvori',
collapse: 'Skupi',
@@ -301,6 +302,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = {
filterWhere: 'Filter {{label}} gde',
globals: 'Globali',
goBack: 'Vrati se',
groupByLabel: 'Grupiši po {{label}}',
import: 'Uvoz',
isEditing: 'uređuje',
item: 'stavka',

View File

@@ -232,6 +232,7 @@ export const ruTranslations: DefaultTranslationsObject = {
cancel: 'Отмена',
changesNotSaved:
'Ваши изменения не были сохранены. Если вы сейчас уйдете, то потеряете свои изменения.',
clear: 'Четкий',
clearAll: 'Очистить все',
close: 'Закрыть',
collapse: 'Свернуть',
@@ -303,6 +304,7 @@ export const ruTranslations: DefaultTranslationsObject = {
filterWhere: 'Где фильтровать',
globals: 'Глобальные',
goBack: 'Назад',
groupByLabel: 'Группировать по {{label}}',
import: 'Импорт',
isEditing: 'редактирует',
item: 'предмет',

View File

@@ -232,6 +232,7 @@ export const skTranslations: DefaultTranslationsObject = {
backToDashboard: 'Späť na nástenku',
cancel: 'Zrušiť',
changesNotSaved: 'Vaše zmeny neboli uložené. Ak teraz odídete, stratíte svoje zmeny.',
clear: 'Jasný',
clearAll: 'Vymazať všetko',
close: 'Zavrieť',
collapse: 'Zbaliť',
@@ -302,6 +303,7 @@ export const skTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtrovat kde je {{label}}',
globals: 'Globalné',
goBack: 'Vrátiť sa',
groupByLabel: 'Zoskupiť podľa {{label}}',
import: 'Dovoz',
isEditing: 'upravuje',
item: 'položka',

View File

@@ -230,6 +230,7 @@ export const slTranslations: DefaultTranslationsObject = {
cancel: 'Prekliči',
changesNotSaved:
'Vaše spremembe niso shranjene. Če zapustite zdaj, boste izgubili svoje spremembe.',
clear: 'Čisto',
clearAll: 'Počisti vse',
close: 'Zapri',
collapse: 'Strni',
@@ -300,6 +301,7 @@ export const slTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtriraj {{label}} kjer',
globals: 'Globalne nastavitve',
goBack: 'Nazaj',
groupByLabel: 'Razvrsti po {{label}}',
import: 'Uvoz',
isEditing: 'ureja',
item: 'predmet',

View File

@@ -229,6 +229,7 @@ export const svTranslations: DefaultTranslationsObject = {
cancel: 'Avbryt',
changesNotSaved:
'Dina ändringar har inte sparats. Om du lämnar nu kommer du att förlora dina ändringar.',
clear: 'Tydlig',
clearAll: 'Rensa alla',
close: 'Stänga',
collapse: 'Kollapsa',
@@ -300,6 +301,7 @@ export const svTranslations: DefaultTranslationsObject = {
filterWhere: 'Filtrera {{label}} där',
globals: 'Globala',
goBack: 'Gå tillbaka',
groupByLabel: 'Gruppera efter {{label}}',
import: 'Importera',
isEditing: 'redigerar',
item: 'artikel',

View File

@@ -224,6 +224,8 @@ export const thTranslations: DefaultTranslationsObject = {
backToDashboard: 'กลับไปหน้าแดชบอร์ด',
cancel: 'ยกเลิก',
changesNotSaved: 'การเปลี่ยนแปลงยังไม่ได้ถูกบันทึก ถ้าคุณออกตอนนี้ สิ่งที่แก้ไขไว้จะหายไป',
clear:
'ให้เคารพความหมายของข้อความต้นฉบับภายในบริบทของ Payload นี่คือรายการของคำที่มักใช้ใน Payload ที่มีความหมายที่เฉพาะเจาะจงมาก:\n - Collection: Collection คือกลุ่มของเอกสารที่มีโครงสร้างและจุดประสงค์ท',
clearAll: 'ล้างทั้งหมด',
close: 'ปิด',
collapse: 'ยุบ',
@@ -295,6 +297,7 @@ export const thTranslations: DefaultTranslationsObject = {
filterWhere: 'กรอง {{label}} เฉพาะ',
globals: 'Globals',
goBack: 'กลับไป',
groupByLabel: 'จัดกลุ่มตาม {{label}}',
import: 'นำเข้า',
isEditing: 'กำลังแก้ไข',
item: 'รายการ',

View File

@@ -233,6 +233,7 @@ export const trTranslations: DefaultTranslationsObject = {
cancel: 'İptal',
changesNotSaved:
'Değişiklikleriniz henüz kaydedilmedi. Eğer bu sayfayı terk ederseniz değişiklikleri kaybedeceksiniz.',
clear: 'Temiz',
clearAll: 'Hepsini Temizle',
close: 'Kapat',
collapse: 'Daralt',
@@ -304,6 +305,7 @@ export const trTranslations: DefaultTranslationsObject = {
filterWhere: '{{label}} filtrele:',
globals: 'Globaller',
goBack: 'Geri dön',
groupByLabel: "{{label}}'ye göre grupla",
import: 'İthalat',
isEditing: 'düzenliyor',
item: 'öğe',

View File

@@ -230,6 +230,7 @@ export const ukTranslations: DefaultTranslationsObject = {
backToDashboard: 'Повернутись до головної сторінки',
cancel: 'Скасувати',
changesNotSaved: 'Ваши зміни не були збережені. Якщо ви вийдете зараз, то втратите свої зміни.',
clear: 'Чітко',
clearAll: 'Очистити все',
close: 'Закрити',
collapse: 'Згорнути',
@@ -300,6 +301,7 @@ export const ukTranslations: DefaultTranslationsObject = {
filterWhere: 'Де фільтрувати {{label}}',
globals: 'Глобальні',
goBack: 'Повернутися',
groupByLabel: 'Групувати за {{label}}',
import: 'Імпорт',
isEditing: 'редагує',
item: 'предмет',

View File

@@ -228,6 +228,7 @@ export const viTranslations: DefaultTranslationsObject = {
backToDashboard: 'Quay lại bảng điều khiển',
cancel: 'Hủy',
changesNotSaved: 'Thay đổi chưa được lưu lại. Bạn sẽ mất bản chỉnh sửa nếu thoát bây giờ.',
clear: 'Rõ ràng',
clearAll: 'Xóa tất cả',
close: 'Gần',
collapse: 'Thu gọn',
@@ -299,6 +300,7 @@ export const viTranslations: DefaultTranslationsObject = {
filterWhere: 'Lọc {{label}} với điều kiện:',
globals: 'Toàn thể (globals)',
goBack: 'Quay lại',
groupByLabel: 'Nhóm theo {{label}}',
import: 'Nhập khẩu',
isEditing: 'đang chỉnh sửa',
item: 'mặt hàng',

View File

@@ -218,6 +218,7 @@ export const zhTranslations: DefaultTranslationsObject = {
backToDashboard: '返回到仪表板',
cancel: '取消',
changesNotSaved: '您的更改尚未保存。您确定要离开吗?',
clear: '清晰',
clearAll: '清除全部',
close: '关闭',
collapse: '折叠',
@@ -286,6 +287,7 @@ export const zhTranslations: DefaultTranslationsObject = {
filterWhere: '过滤{{label}}',
globals: '全局',
goBack: '返回',
groupByLabel: '按{{label}}分组',
import: '导入',
isEditing: '正在编辑',
item: '条目',

View File

@@ -217,6 +217,7 @@ export const zhTwTranslations: DefaultTranslationsObject = {
backToDashboard: '返回到控制面板',
cancel: '取消',
changesNotSaved: '您還有尚未儲存的變更。您確定要離開嗎?',
clear: '清晰',
clearAll: '清除全部',
close: '關閉',
collapse: '折疊',
@@ -285,6 +286,7 @@ export const zhTwTranslations: DefaultTranslationsObject = {
filterWhere: '過濾{{label}}',
globals: '全域',
goBack: '返回',
groupByLabel: '按照 {{label}} 分類',
import: '進口',
isEditing: '正在編輯',
item: '物品',

View File

@@ -21,7 +21,7 @@ export const ColumnSelector: React.FC<Props> = ({ collectionSlug }) => {
const filteredColumns = useMemo(
() =>
columns.filter(
columns?.filter(
(col) =>
!(fieldIsHiddenOrDisabled(col.field) && !fieldIsID(col.field)) &&
!col?.field?.admin?.disableListColumn,

View File

@@ -20,18 +20,23 @@ import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import { ConfirmationModal } from '../ConfirmationModal/index.js'
import { ListSelectionButton } from '../ListSelection/index.js'
const confirmManyDeleteDrawerSlug = `confirm-delete-many-docs`
export type Props = {
collection: ClientCollectionConfig
/**
* When multiple DeleteMany components are rendered on the page, this will differentiate them.
*/
modalPrefix?: string
/**
* When multiple PublishMany components are rendered on the page, this will differentiate them.
*/
title?: string
}
export const DeleteMany: React.FC<Props> = (props) => {
const { collection: { slug } = {} } = props
const { collection: { slug } = {}, modalPrefix } = props
const { permissions } = useAuth()
const { count, getSelectedIds, selectAll, toggleAll } = useSelection()
const { count, selectAll, selectedIDs, toggleAll } = useSelection()
const router = useRouter()
const searchParams = useSearchParams()
const { clearRouteCache } = useRouteCache()
@@ -39,13 +44,14 @@ export const DeleteMany: React.FC<Props> = (props) => {
const collectionPermissions = permissions?.collections?.[slug]
const hasDeletePermission = collectionPermissions?.delete
const selectingAll = selectAll === SelectAllStatus.AllAvailable
const ids = selectingAll ? [] : selectedIDs
if (selectAll === SelectAllStatus.None || !hasDeletePermission) {
return null
}
const selectingAll = selectAll === SelectAllStatus.AllAvailable
const selectedIDs = !selectingAll ? getSelectedIds() : []
return (
<React.Fragment>
<DeleteMany_v4
@@ -64,12 +70,13 @@ export const DeleteMany: React.FC<Props> = (props) => {
clearRouteCache()
}}
modalPrefix={modalPrefix}
search={parseSearchParams(searchParams)?.search as string}
selections={{
[slug]: {
all: selectAll === SelectAllStatus.AllAvailable,
ids: selectedIDs,
totalCount: selectingAll ? count : selectedIDs.length,
ids,
totalCount: selectingAll ? count : ids.length,
},
}}
where={parseSearchParams(searchParams)?.where as Where}
@@ -91,6 +98,10 @@ type DeleteMany_v4Props = {
* A callback function to be called after the delete request is completed.
*/
afterDelete?: (result: AfterDeleteResult) => void
/**
* When multiple DeleteMany components are rendered on the page, this will differentiate them.
*/
modalPrefix?: string
/**
* Optionally pass a search string to filter the documents to be deleted.
*
@@ -126,8 +137,15 @@ type DeleteMany_v4Props = {
*
* If you are deleting monomorphic documents, shape your `selections` to match the polymorphic structure.
*/
export function DeleteMany_v4({ afterDelete, search, selections, where }: DeleteMany_v4Props) {
export function DeleteMany_v4({
afterDelete,
modalPrefix,
search,
selections,
where,
}: DeleteMany_v4Props) {
const { t } = useTranslation()
const {
config: {
collections,
@@ -135,15 +153,20 @@ export function DeleteMany_v4({ afterDelete, search, selections, where }: Delete
serverURL,
},
} = useConfig()
const { code: locale } = useLocale()
const { i18n } = useTranslation()
const { openModal } = useModal()
const confirmManyDeleteDrawerSlug = `${modalPrefix ? `${modalPrefix}-` : ''}confirm-delete-many-docs`
const handleDelete = React.useCallback(async () => {
const deletingOneCollection = Object.keys(selections).length === 1
const result: AfterDeleteResult = {}
for (const [relationTo, { all, ids = [] }] of Object.entries(selections)) {
const collectionConfig = collections.find(({ slug }) => slug === relationTo)
if (collectionConfig) {
let whereConstraint: Where
@@ -153,7 +176,9 @@ export function DeleteMany_v4({ afterDelete, search, selections, where }: Delete
whereConstraint = where
} else {
whereConstraint = {
id: { not_equals: '' },
id: {
not_equals: '',
},
}
}
} else {
@@ -219,6 +244,7 @@ export function DeleteMany_v4({ afterDelete, search, selections, where }: Delete
toast.error(t('error:unknown'))
result[relationTo].errors = [t('error:unknown')]
}
continue
} catch (_err) {
toast.error(t('error:unknown'))
@@ -247,7 +273,9 @@ export function DeleteMany_v4({ afterDelete, search, selections, where }: Delete
value.totalCount > 1 ? collectionConfig.labels.plural : collectionConfig.labels.singular,
i18n,
)}`
let newLabel
if (index === array.length - 1 && index !== 0) {
newLabel = `${acc.label} and ${collectionLabel}`
} else if (index > 0) {

View File

@@ -139,7 +139,9 @@ type EditManyDrawerContentProps = {
* The function to set the selected fields to bulk edit
*/
setSelectedFields: (fields: FieldOption[]) => void
where?: Where
} & EditManyProps
export const EditManyDrawerContent: React.FC<EditManyDrawerContentProps> = (props) => {
const {
collection,
@@ -151,6 +153,7 @@ export const EditManyDrawerContent: React.FC<EditManyDrawerContentProps> = (prop
selectAll,
selectedFields,
setSelectedFields,
where,
} = props
const { permissions, user } = useAuth()
@@ -220,6 +223,10 @@ export const EditManyDrawerContent: React.FC<EditManyDrawerContentProps> = (prop
const queryString = useMemo((): string => {
const whereConstraints: Where[] = []
if (where) {
whereConstraints.push(where)
}
const queryWithSearch = mergeListSearchAndWhere({
collectionConfig: collection,
search: searchParams.get('search'),
@@ -234,7 +241,7 @@ export const EditManyDrawerContent: React.FC<EditManyDrawerContentProps> = (prop
whereConstraints.push(
(parseSearchParams(searchParams)?.where as Where) || {
id: {
exists: true,
not_equals: '',
},
},
)
@@ -254,7 +261,7 @@ export const EditManyDrawerContent: React.FC<EditManyDrawerContentProps> = (prop
},
{ addQueryPrefix: true },
)
}, [collection, searchParams, selectAll, ids, locale])
}, [collection, searchParams, selectAll, ids, locale, where])
const onSuccess = () => {
router.replace(

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import type { ClientCollectionConfig, Where } from 'payload'
import { useModal } from '@faceless-ui/modal'
import React, { useState } from 'react'
@@ -11,9 +11,9 @@ import { EditDepthProvider } from '../../providers/EditDepth/index.js'
import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { Drawer } from '../Drawer/index.js'
import './index.scss'
import { ListSelectionButton } from '../ListSelection/index.js'
import { EditManyDrawerContent } from './DrawerContent.js'
import './index.scss'
export const baseClass = 'edit-many'
@@ -22,13 +22,14 @@ export type EditManyProps = {
}
export const EditMany: React.FC<EditManyProps> = (props) => {
const { count, selectAll, selected, toggleAll } = useSelection()
const { count, selectAll, selectedIDs, toggleAll } = useSelection()
return (
<EditMany_v4
{...props}
count={count}
ids={Array.from(selected.keys())}
onSuccess={() => toggleAll(false)}
ids={selectedIDs}
onSuccess={() => toggleAll()}
selectAll={selectAll === SelectAllStatus.AllAvailable}
/>
)
@@ -38,10 +39,15 @@ export const EditMany_v4: React.FC<
{
count: number
ids: (number | string)[]
/**
* When multiple EditMany components are rendered on the page, this will differentiate them.
*/
modalPrefix?: string
onSuccess?: () => void
selectAll: boolean
} & EditManyProps
> = ({ collection, count, ids, onSuccess, selectAll }) => {
where?: Where
} & Omit<EditManyProps, 'ids'>
> = ({ collection, count, ids, modalPrefix, onSuccess, selectAll, where }) => {
const { permissions } = useAuth()
const { openModal } = useModal()
@@ -51,7 +57,7 @@ export const EditMany_v4: React.FC<
const collectionPermissions = permissions?.collections?.[collection.slug]
const drawerSlug = `edit-${collection.slug}`
const drawerSlug = `${modalPrefix ? `${modalPrefix}-` : ''}edit-${collection.slug}`
if (count === 0 || !collectionPermissions?.update) {
return null
@@ -79,6 +85,7 @@ export const EditMany_v4: React.FC<
selectAll={selectAll}
selectedFields={selectedFields}
setSelectedFields={setSelectedFields}
where={where}
/>
</Drawer>
</EditDepthProvider>

View File

@@ -0,0 +1,39 @@
@import '../../scss/styles.scss';
@layer payload-default {
.group-by-builder {
background: var(--theme-elevation-50);
padding: var(--base);
display: flex;
flex-direction: column;
gap: calc(var(--base) / 2);
&__header {
width: 100%;
display: flex;
justify-content: space-between;
}
&__clear-button {
background: transparent;
border: none;
color: var(--theme-elevation-500);
line-height: inherit;
cursor: pointer;
font-size: inherit;
padding: 0;
text-decoration: underline;
}
&__inputs {
width: 100%;
display: flex;
gap: base(1);
& > * {
flex-grow: 1;
width: 50%;
}
}
}
}

View File

@@ -0,0 +1,144 @@
'use client'
import type { ClientField, Field, SanitizedCollectionConfig } from 'payload'
import './index.scss'
import React, { useMemo } from 'react'
import { SelectInput } from '../../fields/Select/Input.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { reduceFieldsToOptions } from '../../utilities/reduceFieldsToOptions.js'
import { ReactSelect } from '../ReactSelect/index.js'
export type Props = {
readonly collectionSlug: SanitizedCollectionConfig['slug']
fields: ClientField[]
}
const baseClass = 'group-by-builder'
/**
* Note: Some fields are already omitted from the list of fields:
* - fields with nested field, e.g. `tabs`, `groups`, etc.
* - fields that don't affect data, i.e. `row`, `collapsible`, `ui`, etc.
* So we don't technically need to omit them here, but do anyway.
* But some remaining fields still need an additional check, e.g. `richText`, etc.
*/
const supportedFieldTypes: Field['type'][] = [
'text',
'textarea',
'number',
'select',
'relationship',
'date',
'checkbox',
'radio',
'email',
'number',
'upload',
]
export const GroupByBuilder: React.FC<Props> = ({ collectionSlug, fields }) => {
const { i18n, t } = useTranslation()
const reducedFields = useMemo(() => reduceFieldsToOptions({ fields, i18n }), [fields, i18n])
const { query, refineListData } = useListQuery()
const groupByFieldName = query.groupBy?.replace(/^-/, '')
const groupByField = reducedFields.find((field) => field.value === groupByFieldName)
return (
<div className={baseClass}>
<div className={`${baseClass}__header`}>
<p>
{t('general:groupByLabel', {
label: '',
})}
</p>
{query.groupBy && (
<button
className={`${baseClass}__clear-button`}
id="group-by--reset"
onClick={async () => {
await refineListData({
groupBy: '',
})
}}
type="button"
>
{t('general:clear')}
</button>
)}
</div>
<div className={`${baseClass}__inputs`}>
<ReactSelect
filterOption={(option, inputValue) =>
((option?.data?.plainTextLabel as string) || option.label)
.toLowerCase()
.includes(inputValue.toLowerCase())
}
id="group-by--field-select"
isClearable
isMulti={false}
onChange={async (v: { value: string } | null) => {
const value = v === null ? undefined : v.value
// value is being cleared
if (v === null) {
await refineListData({
groupBy: '',
page: 1,
})
}
await refineListData({
groupBy: value ? (query.groupBy?.startsWith('-') ? `-${value}` : value) : undefined,
page: 1,
})
}}
options={reducedFields.filter(
(field) =>
!field.field.admin.disableListFilter &&
field.value !== 'id' &&
supportedFieldTypes.includes(field.field.type),
)}
value={{
label: groupByField?.label || t('general:selectValue'),
value: groupByFieldName || '',
}}
/>
<SelectInput
id="group-by--sort"
isClearable={false}
name="direction"
onChange={async ({ value }: { value: string }) => {
if (!groupByFieldName) {
return
}
await refineListData({
groupBy: value === 'asc' ? groupByFieldName : `-${groupByFieldName}`,
page: 1,
})
}}
options={[
{ label: t('general:ascending'), value: 'asc' },
{ label: t('general:descending'), value: 'desc' },
]}
path="direction"
readOnly={!groupByFieldName}
value={
!query.groupBy
? 'asc'
: typeof query.groupBy === 'string'
? `${query.groupBy.startsWith('-') ? 'desc' : 'asc'}`
: ''
}
/>
</div>
</div>
)
}

View File

@@ -36,7 +36,8 @@
.pill-selector,
.where-builder,
.sort-complex {
.sort-complex,
.group-by-builder {
margin-top: base(1);
}
@@ -90,7 +91,8 @@
&__toggle-columns,
&__toggle-where,
&__toggle-sort {
&__toggle-sort,
&__toggle-group-by {
flex: 1;
}
}

View File

@@ -16,6 +16,7 @@ import { useListQuery } from '../../providers/ListQuery/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { AnimateHeight } from '../AnimateHeight/index.js'
import { ColumnSelector } from '../ColumnSelector/index.js'
import { GroupByBuilder } from '../GroupByBuilder/index.js'
import { Pill } from '../Pill/index.js'
import { SearchFilter } from '../SearchFilter/index.js'
import { WhereBuilder } from '../WhereBuilder/index.js'
@@ -97,7 +98,8 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
const hasWhereParam = useRef(Boolean(query?.where))
const shouldInitializeWhereOpened = validateWhereQuery(query?.where)
const [visibleDrawer, setVisibleDrawer] = useState<'columns' | 'sort' | 'where'>(
const [visibleDrawer, setVisibleDrawer] = useState<'columns' | 'group-by' | 'sort' | 'where'>(
shouldInitializeWhereOpened ? 'where' : undefined,
)
@@ -140,7 +142,7 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
let listMenuItems: React.ReactNode[] = listMenuItemsFromProps
if (
collectionConfig?.enableQueryPresets &&
collectionConfig.enableQueryPresets &&
!disableQueryPresets &&
queryPresetMenuItems?.length > 0
) {
@@ -160,7 +162,6 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
<SearchFilter
handleChange={handleSearchChange}
key={collectionSlug}
// eslint-disable-next-line react-compiler/react-compiler -- TODO: fix
label={searchLabelTranslated.current}
searchQueryParam={query?.search}
/>
@@ -176,6 +177,7 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
aria-expanded={visibleDrawer === 'columns'}
className={`${baseClass}__toggle-columns`}
icon={<ChevronIcon direction={visibleDrawer === 'columns' ? 'up' : 'down'} />}
id="toggle-columns"
onClick={() =>
setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined)
}
@@ -191,6 +193,7 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
aria-expanded={visibleDrawer === 'where'}
className={`${baseClass}__toggle-where`}
icon={<ChevronIcon direction={visibleDrawer === 'where' ? 'up' : 'down'} />}
id="toggle-list-filters"
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)}
pillStyle="light"
size="small"
@@ -218,6 +221,24 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
resetPreset={resetPreset}
/>
)}
{collectionConfig.admin.groupBy && (
<Pill
aria-controls={`${baseClass}-group-by`}
aria-expanded={visibleDrawer === 'group-by'}
className={`${baseClass}__toggle-group-by`}
icon={<ChevronIcon direction={visibleDrawer === 'group-by' ? 'up' : 'down'} />}
id="toggle-group-by"
onClick={() =>
setVisibleDrawer(visibleDrawer !== 'group-by' ? 'group-by' : undefined)
}
pillStyle="light"
size="small"
>
{t('general:groupByLabel', {
label: '',
})}
</Pill>
)}
{listMenuItems && Array.isArray(listMenuItems) && listMenuItems.length > 0 && (
<Popup
button={<Dots ariaLabel={t('general:moreOptions')} />}
@@ -250,13 +271,25 @@ export const ListControls: React.FC<ListControlsProps> = (props) => {
id={`${baseClass}-where`}
>
<WhereBuilder
collectionPluralLabel={collectionConfig?.labels?.plural}
collectionPluralLabel={collectionConfig.labels?.plural}
collectionSlug={collectionConfig.slug}
fields={collectionConfig?.fields}
fields={collectionConfig.fields}
renderedFilters={renderedFilters}
resolvedFilterOptions={resolvedFilterOptions}
/>
</AnimateHeight>
{collectionConfig.admin.groupBy && (
<AnimateHeight
className={`${baseClass}__group-by`}
height={visibleDrawer === 'group-by' ? 'auto' : 0}
id={`${baseClass}-group-by`}
>
<GroupByBuilder
collectionSlug={collectionConfig.slug}
fields={collectionConfig.fields}
/>
</AnimateHeight>
)}
</div>
{PresetListDrawer}
{EditPresetDrawer}

View File

@@ -0,0 +1,62 @@
'use client'
import type { ClientCollectionConfig, PaginatedDocs } from 'payload'
import React, { useCallback } from 'react'
import type { IListQueryContext } from '../../providers/ListQuery/types.js'
import { useListQuery } from '../../providers/ListQuery/context.js'
import { PageControlsComponent } from './index.js'
/**
* If `groupBy` is set in the query, multiple tables will render, one for each group.
* In this case, each table needs its own `PageControls` to handle pagination.
* These page controls, however, should not modify the global `ListQuery` state.
* Instead, they should only handle the pagination for the current group.
* To do this, build a wrapper around `PageControlsComponent` that handles the pagination logic for the current group.
*/
export const GroupByPageControls: React.FC<{
AfterPageControls?: React.ReactNode
collectionConfig: ClientCollectionConfig
data: PaginatedDocs
groupByValue?: number | string
}> = ({ AfterPageControls, collectionConfig, data, groupByValue }) => {
const { refineListData } = useListQuery()
const handlePageChange: IListQueryContext['handlePageChange'] = useCallback(
async (page) => {
await refineListData({
queryByGroup: {
[groupByValue]: {
page,
},
},
})
},
[refineListData, groupByValue],
)
const handlePerPageChange: IListQueryContext['handlePerPageChange'] = useCallback(
async (limit) => {
await refineListData({
queryByGroup: {
[groupByValue]: {
limit,
page: 1,
},
},
})
},
[refineListData, groupByValue],
)
return (
<PageControlsComponent
AfterPageControls={AfterPageControls}
collectionConfig={collectionConfig}
data={data}
handlePageChange={handlePageChange}
handlePerPageChange={handlePerPageChange}
/>
)
}

View File

@@ -0,0 +1,40 @@
@import '../../scss/styles.scss';
@layer payload-default {
.page-controls {
width: 100%;
display: flex;
align-items: center;
&__page-info {
[dir='ltr'] & {
margin-right: base(1);
margin-left: auto;
}
[dir='rtl'] & {
margin-left: base(1);
margin-right: auto;
}
}
@include small-break {
flex-wrap: wrap;
&__page-info {
[dir='ltr'] & {
margin-left: base(0.5);
}
[dir='rtl'] & {
margin-right: 0;
}
}
.paginator {
width: 100%;
margin-bottom: base(0.5);
}
}
}
}

View File

@@ -0,0 +1,94 @@
import type { ClientCollectionConfig, PaginatedDocs } from 'payload'
import { isNumber } from 'payload/shared'
import React, { Fragment } from 'react'
import type { IListQueryContext } from '../../providers/ListQuery/types.js'
import { Pagination } from '../../elements/Pagination/index.js'
import { PerPage } from '../../elements/PerPage/index.js'
import { useListQuery } from '../../providers/ListQuery/context.js'
import { useTranslation } from '../../providers/Translation/index.js'
import './index.scss'
const baseClass = 'page-controls'
export const PageControlsComponent: React.FC<{
AfterPageControls?: React.ReactNode
collectionConfig: ClientCollectionConfig
data: PaginatedDocs
handlePageChange?: IListQueryContext['handlePageChange']
handlePerPageChange?: IListQueryContext['handlePerPageChange']
limit?: number
}> = ({
AfterPageControls,
collectionConfig,
data,
handlePageChange,
handlePerPageChange,
limit,
}) => {
const { i18n } = useTranslation()
return (
<div className={baseClass}>
<Pagination
hasNextPage={data.hasNextPage}
hasPrevPage={data.hasPrevPage}
limit={data.limit}
nextPage={data.nextPage}
numberOfNeighbors={1}
onChange={handlePageChange}
page={data.page}
prevPage={data.prevPage}
totalPages={data.totalPages}
/>
{data.totalDocs > 0 && (
<Fragment>
<div className={`${baseClass}__page-info`}>
{data.page * data.limit - (data.limit - 1)}-
{data.totalPages > 1 && data.totalPages !== data.page
? data.limit * data.page
: data.totalDocs}{' '}
{i18n.t('general:of')} {data.totalDocs}
</div>
<PerPage
handleChange={handlePerPageChange}
limit={limit}
limits={collectionConfig?.admin?.pagination?.limits}
resetPage={data.totalDocs <= data.pagingCounter}
/>
{AfterPageControls}
</Fragment>
)}
</div>
)
}
/*
* These page controls are controlled by the global ListQuery state.
* To override thi behavior, build your own wrapper around PageControlsComponent.
*/
export const PageControls: React.FC<{
AfterPageControls?: React.ReactNode
collectionConfig: ClientCollectionConfig
}> = ({ AfterPageControls, collectionConfig }) => {
const {
data,
defaultLimit: initialLimit,
handlePageChange,
handlePerPageChange,
query,
} = useListQuery()
return (
<PageControlsComponent
AfterPageControls={AfterPageControls}
collectionConfig={collectionConfig}
data={data}
handlePageChange={handlePageChange}
handlePerPageChange={handlePerPageChange}
limit={isNumber(query.limit) ? query.limit : initialLimit}
/>
)
}

View File

@@ -4,14 +4,14 @@
.clickable-arrow {
cursor: pointer;
@extend %btn-reset;
width: base(2);
height: base(2);
width: base(1.5);
height: base(1.5);
display: flex;
justify-content: center;
align-content: center;
align-items: center;
outline: 0;
padding: base(0.5);
padding: base(0.25);
color: var(--theme-elevation-800);
line-height: base(1);

View File

@@ -3,7 +3,6 @@
@layer payload-default {
.paginator {
display: flex;
margin-bottom: $baseline;
&__page {
cursor: pointer;
@@ -25,15 +24,16 @@
&__page {
@extend %btn-reset;
width: base(2);
height: base(2);
width: base(1.5);
height: base(1.5);
display: flex;
justify-content: center;
align-content: center;
outline: 0;
border-radius: var(--style-radius-s);
padding: base(0.5);
color: var(--theme-elevation-800);
line-height: base(1);
line-height: 0.9;
&:focus-visible {
outline: var(--accessibility-outline);

View File

@@ -52,7 +52,7 @@ export const Pagination: React.FC<PaginationProps> = (props) => {
totalPages = null,
} = props
if (!hasNextPage && !hasPrevPage) {
if (!hasPrevPage && !hasNextPage) {
return null
}

View File

@@ -22,7 +22,9 @@ type PublishManyDrawerContentProps = {
ids: (number | string)[]
onSuccess?: () => void
selectAll: boolean
where?: Where
} & PublishManyProps
export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) {
const {
collection,
@@ -31,15 +33,18 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) {
ids,
onSuccess,
selectAll,
where,
} = props
const { clearRouteCache } = useRouteCache()
const {
config: {
routes: { api },
serverURL,
},
} = useConfig()
const { code: locale } = useLocale()
const router = useRouter()
@@ -59,6 +64,10 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) {
},
]
if (where) {
whereConstraints.push(where)
}
const queryWithSearch = mergeListSearchAndWhere({
collectionConfig: collection,
search: searchParams.get('search'),
@@ -73,7 +82,7 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) {
whereConstraints.push(
(parseSearchParams(searchParams)?.where as Where) || {
id: {
exists: true,
not_equals: '',
},
},
)
@@ -93,7 +102,7 @@ export function PublishManyDrawerContent(props: PublishManyDrawerContentProps) {
},
{ addQueryPrefix: true },
)
}, [collection, searchParams, selectAll, ids, locale])
}, [collection, searchParams, selectAll, ids, locale, where])
const handlePublish = useCallback(async () => {
await requests

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import type { ClientCollectionConfig, Where } from 'payload'
import { useModal } from '@faceless-ui/modal'
import React from 'react'
@@ -15,14 +15,14 @@ export type PublishManyProps = {
}
export const PublishMany: React.FC<PublishManyProps> = (props) => {
const { count, selectAll, selected, toggleAll } = useSelection()
const { count, selectAll, selectedIDs, toggleAll } = useSelection()
return (
<PublishMany_v4
{...props}
count={count}
ids={Array.from(selected.keys())}
onSuccess={() => toggleAll(false)}
ids={selectedIDs}
onSuccess={() => toggleAll()}
selectAll={selectAll === SelectAllStatus.AllAvailable}
/>
)
@@ -31,17 +31,25 @@ export const PublishMany: React.FC<PublishManyProps> = (props) => {
type PublishMany_v4Props = {
count: number
ids: (number | string)[]
/**
* When multiple PublishMany components are rendered on the page, this will differentiate them.
*/
modalPrefix?: string
onSuccess?: () => void
selectAll: boolean
where?: Where
} & PublishManyProps
export const PublishMany_v4: React.FC<PublishMany_v4Props> = (props) => {
const {
collection,
collection: { slug, versions } = {},
count,
ids,
modalPrefix,
onSuccess,
selectAll,
where,
} = props
const { permissions } = useAuth()
@@ -52,7 +60,7 @@ export const PublishMany_v4: React.FC<PublishMany_v4Props> = (props) => {
const collectionPermissions = permissions?.collections?.[slug]
const hasPermission = collectionPermissions?.update
const drawerSlug = `publish-${slug}`
const drawerSlug = `${modalPrefix ? `${modalPrefix}-` : ''}publish-${slug}`
if (!versions?.drafts || count === 0 || !hasPermission) {
return null
@@ -74,6 +82,7 @@ export const PublishMany_v4: React.FC<PublishMany_v4Props> = (props) => {
ids={ids}
onSuccess={onSuccess}
selectAll={selectAll}
where={where}
/>
</React.Fragment>
)

View File

@@ -84,6 +84,7 @@ export type ReactSelectAdapterProps = {
boolean,
GroupBase<Option>
>['getOptionValue']
id?: string
inputId?: string
isClearable?: boolean
/** Allows you to create own values in the UI despite them not being pre-specified */

View File

@@ -112,7 +112,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
const { getTableState } = useServerFunctions()
const renderTable = useCallback(
async (docs?: PaginatedDocs['docs']) => {
async (data?: PaginatedDocs) => {
const newQuery: ListQuery = {
limit: field?.defaultLimit || collectionConfig?.admin?.pagination?.defaultLimit,
sort: field.defaultSort || collectionConfig?.defaultSort,
@@ -139,7 +139,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
} = await getTableState({
collectionSlug: relationTo,
columns: transformColumnsToPreferences(query?.columns) || defaultColumns,
docs,
data,
enableRowSelections: false,
orderableFieldName:
!field.orderable || Array.isArray(field.collection)
@@ -190,17 +190,17 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
const onDrawerSave = useCallback<DocumentDrawerProps['onSave']>(
(args) => {
const foundDocIndex = data?.docs?.findIndex((doc) => doc.id === args.doc.id)
let withNewOrUpdatedDoc: PaginatedDocs['docs'] = undefined
const withNewOrUpdatedData: PaginatedDocs = { docs: [] } as PaginatedDocs
if (foundDocIndex !== -1) {
const newDocs = [...data.docs]
newDocs[foundDocIndex] = args.doc
withNewOrUpdatedDoc = newDocs
withNewOrUpdatedData.docs = newDocs
} else {
withNewOrUpdatedDoc = [args.doc, ...data.docs]
withNewOrUpdatedData.docs = [args.doc, ...data.docs]
}
void renderTable(withNewOrUpdatedDoc)
void renderTable(withNewOrUpdatedData)
},
[data?.docs, renderTable],
)
@@ -217,9 +217,13 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
const onDrawerDelete = useCallback<DocumentDrawerProps['onDelete']>(
(args) => {
const newDocs = data.docs.filter((doc) => doc.id !== args.id)
void renderTable(newDocs)
void renderTable({
...data,
docs: newDocs,
})
},
[data?.docs, renderTable],
[data, renderTable],
)
const canCreate =
@@ -240,13 +244,13 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDrawerOpen])
const memoizedQuery = React.useMemo(
const memoizedListQuery = React.useMemo(
() => ({
columns: transformColumnsToPreferences(columnState)?.map(({ accessor }) => accessor),
limit: field.defaultLimit ?? collectionConfig?.admin?.pagination?.defaultLimit,
sort: field.defaultSort ?? collectionConfig?.defaultSort,
}),
[field, columnState, collectionConfig],
[columnState, field, collectionConfig],
)
return (
@@ -323,7 +327,7 @@ export const RelationshipTable: React.FC<RelationshipTableComponentProps> = (pro
? undefined
: `_${field.collection}_${fieldPath.replaceAll('.', '_')}_order`
}
query={memoizedQuery}
query={memoizedListQuery}
>
<TableColumnsProvider
collectionSlug={isPolymorphic ? relationTo[0] : relationTo}

View File

@@ -0,0 +1,27 @@
@import '../../scss/styles.scss';
@layer payload-default {
.sticky-toolbar {
padding: base(0.5);
position: sticky;
bottom: var(--base);
background-color: var(--theme-bg);
border: 1px solid var(--theme-elevation-100);
max-width: 500px;
z-index: 1;
margin-left: 50%;
transform: translate3d(-50%, 0, 0);
border-radius: var(--style-radius-m);
box-shadow: 0px 4px 16px 0px rgba(0, 0, 0, 0.1);
margin-top: base(2);
@include small-break {
width: calc(100% - var(--base) * 2);
margin-left: var(--base);
transform: none;
position: relative;
max-width: unset;
margin-bottom: base(4);
}
}
}

View File

@@ -0,0 +1,9 @@
import React from 'react'
import './index.scss'
const baseClass = 'sticky-toolbar'
export const StickyToolbar: React.FC<{
children: React.ReactNode
}> = ({ children }) => <div className={baseClass}>{children}</div>

View File

@@ -19,13 +19,16 @@ const baseClass = 'table'
export type Props = {
readonly appearance?: 'condensed' | 'default'
readonly BeforeTable?: React.ReactNode
readonly collection: ClientCollectionConfig
readonly columns?: Column[]
readonly data: Record<string, unknown>[]
readonly heading?: React.ReactNode
}
export const OrderableTable: React.FC<Props> = ({
appearance = 'default',
BeforeTable,
collection,
columns,
data: initialData,
@@ -163,6 +166,7 @@ export const OrderableTable: React.FC<Props> = ({
.filter(Boolean)
.join(' ')}
>
{BeforeTable}
<DraggableSortable ids={rowIds} onDragEnd={handleDragEnd} onDragStart={handleDragStart}>
<table cellPadding="0" cellSpacing="0">
<thead>

View File

@@ -10,11 +10,12 @@ const baseClass = 'table'
export type Props = {
readonly appearance?: 'condensed' | 'default'
readonly BeforeTable?: React.ReactNode
readonly columns?: Column[]
readonly data: Record<string, unknown>[]
}
export const Table: React.FC<Props> = ({ appearance, columns, data }) => {
export const Table: React.FC<Props> = ({ appearance, BeforeTable, columns, data }) => {
const activeColumns = columns?.filter((col) => col?.active)
if (!activeColumns || activeColumns.length === 0) {
@@ -27,6 +28,7 @@ export const Table: React.FC<Props> = ({ appearance, columns, data }) => {
.filter(Boolean)
.join(' ')}
>
{BeforeTable}
<table cellPadding="0" cellSpacing="0">
<thead>
<tr>
@@ -39,7 +41,7 @@ export const Table: React.FC<Props> = ({ appearance, columns, data }) => {
</thead>
<tbody>
{data &&
data.map((row, rowIndex) => (
data?.map((row, rowIndex) => (
<tr
className={`row-${rowIndex + 1}`}
key={

View File

@@ -22,6 +22,7 @@ type UnpublishManyDrawerContentProps = {
ids: (number | string)[]
onSuccess?: () => void
selectAll: boolean
where?: Where
} & UnpublishManyProps
export function UnpublishManyDrawerContent(props: UnpublishManyDrawerContentProps) {
@@ -32,6 +33,7 @@ export function UnpublishManyDrawerContent(props: UnpublishManyDrawerContentProp
ids,
onSuccess,
selectAll,
where,
} = props
const {
@@ -58,6 +60,10 @@ export function UnpublishManyDrawerContent(props: UnpublishManyDrawerContentProp
},
]
if (where) {
whereConstraints.push(where)
}
const queryWithSearch = mergeListSearchAndWhere({
collectionConfig: collection,
search: searchParams.get('search'),
@@ -83,7 +89,7 @@ export function UnpublishManyDrawerContent(props: UnpublishManyDrawerContentProp
},
{ addQueryPrefix: true },
)
}, [collection, searchParams, selectAll, ids, locale])
}, [collection, searchParams, selectAll, ids, locale, where])
const handleUnpublish = useCallback(async () => {
await requests

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import type { ClientCollectionConfig, Where } from 'payload'
import { useModal } from '@faceless-ui/modal'
import React from 'react'
@@ -15,14 +15,14 @@ export type UnpublishManyProps = {
}
export const UnpublishMany: React.FC<UnpublishManyProps> = (props) => {
const { count, selectAll, selected, toggleAll } = useSelection()
const { count, selectAll, selectedIDs, toggleAll } = useSelection()
return (
<UnpublishMany_v4
{...props}
count={count}
ids={Array.from(selected.keys())}
onSuccess={() => toggleAll(false)}
ids={selectedIDs}
onSuccess={() => toggleAll()}
selectAll={selectAll === SelectAllStatus.AllAvailable}
/>
)
@@ -32,8 +32,13 @@ export const UnpublishMany_v4: React.FC<
{
count: number
ids: (number | string)[]
/**
* When multiple UnpublishMany components are rendered on the page, this will differentiate them.
*/
modalPrefix?: string
onSuccess?: () => void
selectAll: boolean
where?: Where
} & UnpublishManyProps
> = (props) => {
const {
@@ -41,8 +46,10 @@ export const UnpublishMany_v4: React.FC<
collection: { slug, versions } = {},
count,
ids,
modalPrefix,
onSuccess,
selectAll,
where,
} = props
const { t } = useTranslation()
@@ -52,7 +59,7 @@ export const UnpublishMany_v4: React.FC<
const collectionPermissions = permissions?.collections?.[slug]
const hasPermission = collectionPermissions?.update
const drawerSlug = `unpublish-${slug}`
const drawerSlug = `${modalPrefix ? `${modalPrefix}-` : ''}unpublish-${slug}`
if (!versions?.drafts || count === 0 || !hasPermission) {
return null
@@ -74,6 +81,7 @@ export const UnpublishMany_v4: React.FC<
ids={ids}
onSuccess={onSuccess}
selectAll={selectAll}
where={where}
/>
</React.Fragment>
)

View File

@@ -9,10 +9,10 @@ import type { AddCondition, RemoveCondition, UpdateCondition, WhereBuilderProps
import { useListQuery } from '../../providers/ListQuery/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { reduceFieldsToOptions } from '../../utilities/reduceFieldsToOptions.js'
import { Button } from '../Button/index.js'
import { Condition } from './Condition/index.js'
import fieldTypes from './field-types.js'
import { reduceFields } from './reduceFields.js'
import './index.scss'
const baseClass = 'where-builder'
@@ -27,7 +27,7 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
const { collectionPluralLabel, fields, renderedFilters, resolvedFilterOptions } = props
const { i18n, t } = useTranslation()
const reducedFields = useMemo(() => reduceFields({ fields, i18n }), [fields, i18n])
const reducedFields = useMemo(() => reduceFieldsToOptions({ fields, i18n }), [fields, i18n])
const { handleWhereChange, query } = useListQuery()
@@ -129,9 +129,9 @@ export const WhereBuilder: React.FC<WhereBuilderProps> = (props) => {
<div className={baseClass}>
{conditions.length > 0 && (
<React.Fragment>
<div className={`${baseClass}__label`}>
<p className={`${baseClass}__label`}>
{t('general:filterWhere', { label: getTranslation(collectionPluralLabel, i18n) })}
</div>
</p>
<ul className={`${baseClass}__or-filters`}>
{conditions.map((or, orIndex) => {
const compoundOrKey = `${orIndex}_${Array.isArray(or?.and) ? or.and.length : ''}`

View File

@@ -101,6 +101,8 @@ export type {
} from '../../elements/ListDrawer/types.js'
export { ListSelection } from '../../views/List/ListSelection/index.js'
export { CollectionListHeader as ListHeader } from '../../views/List/ListHeader/index.js'
export { GroupByHeader } from '../../views/List/GroupByHeader/index.js'
export { GroupByPageControls } from '../../elements/PageControls/GroupByPageControls.js'
export { LoadingOverlayToggle } from '../../elements/Loading/index.js'
export { FormLoadingOverlayToggle } from '../../elements/Loading/index.js'
export { LoadingOverlay } from '../../elements/Loading/index.js'

View File

@@ -24,6 +24,7 @@ export type SelectInputProps = {
readonly Error?: React.ReactNode
readonly filterOption?: ReactSelectAdapterProps['filterOption']
readonly hasMany?: boolean
readonly id?: string
readonly isClearable?: boolean
readonly isSortable?: boolean
readonly Label?: React.ReactNode
@@ -44,6 +45,7 @@ export type SelectInputProps = {
export const SelectInput: React.FC<SelectInputProps> = (props) => {
const {
id,
AfterInput,
BeforeInput,
className,
@@ -121,6 +123,7 @@ export const SelectInput: React.FC<SelectInputProps> = (props) => {
<ReactSelect
disabled={readOnly}
filterOption={filterOption}
id={id}
isClearable={isClearable}
isMulti={hasMany}
isSortable={isSortable}

View File

@@ -10,6 +10,7 @@ import { useListDrawerContext } from '../../elements/ListDrawer/Provider.js'
import { useEffectEvent } from '../../hooks/useEffectEvent.js'
import { useRouteTransition } from '../../providers/RouteTransition/index.js'
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import { useConfig } from '../Config/index.js'
import { ListQueryContext, ListQueryModifiedContext } from './context.js'
import { mergeQuery } from './mergeQuery.js'
import { sanitizeQuery } from './sanitizeQuery.js'
@@ -26,12 +27,15 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
query: queryFromProps,
}) => {
// TODO: Investigate if this is still needed
'use no memo'
// TODO: Investigate if this is still needed
const router = useRouter()
const rawSearchParams = useSearchParams()
const { startRouteTransition } = useRouteTransition()
const [modified, setModified] = useState(false)
const { getEntityConfig } = useConfig()
const collectionConfig = getEntityConfig({ collectionSlug })
const searchParams = useMemo<ListQuery>(
() => sanitizeQuery(parseSearchParams(rawSearchParams)),
@@ -44,7 +48,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
const { onQueryChange } = useListDrawerContext()
const [currentQuery, setCurrentQuery] = useState<ListQuery>(() => {
const [query, setQuery] = useState<ListQuery>(() => {
if (modifySearchParams) {
return searchParams
} else {
@@ -64,7 +68,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
setModified(true)
}
const newQuery = mergeQuery(currentQuery, incomingQuery, {
const newQuery = mergeQuery(query, incomingQuery, {
defaults: {
limit: queryFromProps.limit,
sort: queryFromProps.sort,
@@ -78,6 +82,7 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
{
...newQuery,
columns: JSON.stringify(newQuery.columns),
queryByGroup: JSON.stringify(newQuery.queryByGroup),
},
{ addQueryPrefix: true },
)}`,
@@ -91,10 +96,10 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
onChangeFn(newQuery)
}
setCurrentQuery(newQuery)
setQuery(newQuery)
},
[
currentQuery,
query,
queryFromProps.limit,
queryFromProps.sort,
modifySearchParams,
@@ -128,26 +133,30 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
)
const handleSortChange = useCallback(
async (arg: string) => {
await refineListData({ sort: arg })
async (sort: string) => {
await refineListData({ sort })
},
[refineListData],
)
const handleWhereChange = useCallback(
async (arg: Where) => {
await refineListData({ where: arg })
async (where: Where) => {
await refineListData({ where })
},
[refineListData],
)
const mergeQueryFromPropsAndSyncToURL = useEffectEvent(() => {
const newQuery = sanitizeQuery({ ...(currentQuery || {}), ...(queryFromProps || {}) })
const newQuery = sanitizeQuery({ ...(query || {}), ...(queryFromProps || {}) })
const search = `?${qs.stringify({ ...newQuery, columns: JSON.stringify(newQuery.columns) })}`
const search = `?${qs.stringify({
...newQuery,
columns: JSON.stringify(newQuery.columns),
queryByGroup: JSON.stringify(newQuery.queryByGroup),
})}`
if (window.location.search !== search) {
setCurrentQuery(newQuery)
setQuery(newQuery)
// Important: do not use router.replace here to avoid re-rendering on initial load
window.history.replaceState(null, '', search)
@@ -172,11 +181,11 @@ export const ListQueryProvider: React.FC<ListQueryProps> = ({
handleSearchChange,
handleSortChange,
handleWhereChange,
isGroupingBy: Boolean(collectionConfig?.admin?.groupBy && query?.groupBy),
orderableFieldName,
query: currentQuery,
query,
refineListData,
setModified,
...contextRef.current,
}}
>

View File

@@ -9,10 +9,38 @@ export const mergeQuery = (
): ListQuery => {
let page = 'page' in newQuery ? newQuery.page : currentQuery?.page
if ('where' in newQuery || 'search' in newQuery) {
const shouldResetPage = ['where', 'search', 'groupBy']?.some(
(key) => key in newQuery && !['limit', 'page'].includes(key),
)
if (shouldResetPage) {
page = 1
}
const shouldResetQueryByGroup = ['where', 'search', 'page', 'limit', 'groupBy', 'sort'].some(
(key) => key in newQuery,
)
let mergedQueryByGroup = undefined
if (!shouldResetQueryByGroup) {
// Deeply merge queryByGroup so we can send a partial update for a specific group
mergedQueryByGroup = {
...(currentQuery?.queryByGroup || {}),
...(newQuery.queryByGroup
? Object.fromEntries(
Object.entries(newQuery.queryByGroup).map(([key, value]) => [
key,
{
...(currentQuery?.queryByGroup?.[key] || {}),
...value,
},
]),
)
: {}),
}
}
const mergedQuery: ListQuery = {
...currentQuery,
...newQuery,
@@ -24,6 +52,7 @@ export const mergeQuery = (
limit: 'limit' in newQuery ? newQuery.limit : (currentQuery?.limit ?? options?.defaults?.limit),
page,
preset: 'preset' in newQuery ? newQuery.preset : currentQuery?.preset,
queryByGroup: mergedQueryByGroup,
search: 'search' in newQuery ? newQuery.search : currentQuery?.search,
sort:
'sort' in newQuery

View File

@@ -1,7 +1,7 @@
import type {
ClientCollectionConfig,
ColumnPreference,
ListQuery,
PaginatedDistinctDocs,
PaginatedDocs,
Sort,
Where,
@@ -20,7 +20,7 @@ export type OnListQueryChange = (query: ListQuery) => void
export type ListQueryProps = {
readonly children: React.ReactNode
readonly collectionSlug?: ClientCollectionConfig['slug']
readonly data: PaginatedDocs
readonly data: PaginatedDocs | undefined
readonly modifySearchParams?: boolean
readonly onQueryChange?: OnListQueryChange
readonly orderableFieldName?: string
@@ -28,14 +28,18 @@ export type ListQueryProps = {
* @deprecated
*/
readonly preferenceKey?: string
query?: ListQuery
readonly query?: ListQuery
}
export type IListQueryContext = {
collectionSlug: ClientCollectionConfig['slug']
data: PaginatedDocs
data: ListQueryProps['data']
defaultLimit?: number
defaultSort?: Sort
/**
* @experimental This prop is subject to change. Use at your own risk.
*/
isGroupingBy: boolean
modified: boolean
orderableFieldName?: string
query: ListQuery

View File

@@ -1,11 +1,12 @@
'use client'
import type { ClientUser, Where } from 'payload'
import type { Where } from 'payload'
import { useSearchParams } from 'next/navigation.js'
import * as qs from 'qs-esm'
import React, { createContext, use, useCallback, useEffect, useRef, useState } from 'react'
import React, { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { parseSearchParams } from '../../utilities/parseSearchParams.js'
import { useAuth } from '../Auth/index.js'
import { useListQuery } from '../ListQuery/index.js'
import { useLocale } from '../Locale/index.js'
@@ -24,31 +25,60 @@ type SelectionContext = {
getSelectedIds: () => (number | string)[]
selectAll: SelectAllStatus
selected: Map<number | string, boolean>
selectedIDs: (number | string)[]
setSelection: (id: number | string) => void
/**
* Selects all rows on the current page within the current query.
* If `allAvailable` is true, does not select specific IDs so that the query itself affects all rows across all pages.
*/
toggleAll: (allAvailable?: boolean) => void
totalDocs: number
}
const Context = createContext({} as SelectionContext)
const Context = createContext({
count: undefined,
getQueryParams: (additionalParams?: Where) => '',
getSelectedIds: () => [],
selectAll: undefined,
selected: new Map(),
selectedIDs: [],
setSelection: (id: number | string) => {},
toggleAll: (toggleAll: boolean) => {},
totalDocs: undefined,
} satisfies SelectionContext)
type Props = {
readonly children: React.ReactNode
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly docs: any[]
readonly totalDocs: number
user: ClientUser
}
export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalDocs, user }) => {
const reduceActiveSelections = (selected: Map<number | string, boolean>): (number | string)[] => {
const ids = []
for (const [key, value] of selected) {
if (value) {
ids.push(key)
}
}
return ids
}
export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalDocs }) => {
const contextRef = useRef({} as SelectionContext)
const { user } = useAuth()
const { code: locale } = useLocale()
const [selected, setSelected] = useState<SelectionContext['selected']>(() => {
const rows = new Map()
docs.forEach(({ id }) => {
rows.set(id, false)
})
return rows
})
@@ -57,17 +87,19 @@ export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalD
const searchParams = useSearchParams()
const { query } = useListQuery()
const toggleAll = useCallback(
const toggleAll: SelectionContext['toggleAll'] = useCallback(
(allAvailable = false) => {
const rows = new Map()
if (allAvailable) {
setSelectAll(SelectAllStatus.AllAvailable)
docs.forEach(({ id, _isLocked, _userEditing }) => {
if (!_isLocked || _userEditing?.id === user?.id) {
rows.set(id, true)
}
})
} else if (
// Reset back to `None` if we previously had any type of selection
selectAll === SelectAllStatus.AllAvailable ||
selectAll === SelectAllStatus.AllInPage
) {
@@ -85,7 +117,7 @@ export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalD
[docs, selectAll, user?.id],
)
const setSelection = useCallback(
const setSelection: SelectionContext['setSelection'] = useCallback(
(id) => {
const doc = docs.find((doc) => doc.id === id)
@@ -116,7 +148,9 @@ export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalD
const params = parseSearchParams(searchParams)?.where as Where
where = params || {
id: { not_equals: '' },
id: {
not_equals: '',
},
}
} else {
const ids = []
@@ -151,15 +185,7 @@ export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalD
[selectAll, selected, locale, searchParams],
)
const getSelectedIds = useCallback(() => {
const ids = []
for (const [key, value] of selected) {
if (value) {
ids.push(key)
}
}
return ids
}, [selected])
const getSelectedIds = useCallback(() => reduceActiveSelections(selected), [selected])
useEffect(() => {
if (selectAll === SelectAllStatus.AllAvailable) {
@@ -208,12 +234,15 @@ export const SelectionProvider: React.FC<Props> = ({ children, docs = [], totalD
setSelected(new Map())
}, [query])
const selectedIDs = useMemo(() => reduceActiveSelections(selected), [selected])
contextRef.current = {
count,
getQueryParams,
getSelectedIds,
selectAll,
selected,
selectedIDs,
setSelection,
toggleAll,
totalDocs,

View File

@@ -74,7 +74,7 @@ const buildTableState = async (
const {
collectionSlug,
columns,
docs: docsFromArgs,
data: dataFromArgs,
enableRowSelections,
orderableFieldName,
parent,
@@ -154,12 +154,11 @@ const buildTableState = async (
},
})
let docs = docsFromArgs
let data: PaginatedDocs
let data: PaginatedDocs = dataFromArgs
// lookup docs, if desired, i.e. within `join` field which initialize with `depth: 0`
if (!docs || query) {
if (!data?.docs || query) {
if (Array.isArray(collectionSlug)) {
if (!parent) {
throw new APIError('Unexpected array of collectionSlug, parent must be provided')
@@ -205,7 +204,6 @@ const buildTableState = async (
for (let i = 0; i < segments.length; i++) {
if (i === segments.length - 1) {
data = parentDoc[segments[i]]
docs = data.docs
} else {
parentDoc = parentDoc[segments[i]]
}
@@ -223,7 +221,6 @@ const buildTableState = async (
user: req.user,
where: query?.where,
})
docs = data.docs
}
}
@@ -233,11 +230,12 @@ const buildTableState = async (
collectionConfig,
collections: Array.isArray(collectionSlug) ? collectionSlug : undefined,
columns,
docs,
data,
enableRowSelections,
i18n: req.i18n,
orderableFieldName,
payload,
query,
renderRowTypes,
tableAppearance,
useAsTitle: Array.isArray(collectionSlug)

View File

@@ -5,11 +5,11 @@ import type { ClientField } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { fieldAffectsData, fieldIsHiddenOrDisabled, fieldIsID, tabHasName } from 'payload/shared'
import type { ReducedField } from './types.js'
import type { ReducedField } from '../elements/WhereBuilder/types.js'
import { createNestedClientFieldPath } from '../../forms/Form/createNestedClientFieldPath.js'
import { combineFieldLabel } from '../../utilities/combineFieldLabel.js'
import fieldTypes, { arrayOperators } from './field-types.js'
import fieldTypes, { arrayOperators } from '../elements/WhereBuilder/field-types.js'
import { createNestedClientFieldPath } from '../forms/Form/createNestedClientFieldPath.js'
import { combineFieldLabel } from './combineFieldLabel.js'
type ReduceFieldOptionsArgs = {
fields: ClientField[]
@@ -22,7 +22,7 @@ type ReduceFieldOptionsArgs = {
* Reduces a field map to a flat array of fields with labels and values.
* Used in the WhereBuilder component to render the fields in the dropdown.
*/
export const reduceFields = ({
export const reduceFieldsToOptions = ({
fields,
i18n,
labelPrefix,
@@ -55,7 +55,7 @@ export const reduceFields = ({
if (typeof localizedTabLabel === 'string') {
reduced.push(
...reduceFields({
...reduceFieldsToOptions({
fields: tab.fields,
i18n,
labelPrefix: labelWithPrefix,
@@ -71,7 +71,7 @@ export const reduceFields = ({
// Rows cant have labels, so we need to handle them differently
if (field.type === 'row' && 'fields' in field) {
reduced.push(
...reduceFields({
...reduceFieldsToOptions({
fields: field.fields,
i18n,
labelPrefix,
@@ -89,7 +89,7 @@ export const reduceFields = ({
: localizedTabLabel
reduced.push(
...reduceFields({
...reduceFieldsToOptions({
fields: field.fields,
i18n,
labelPrefix: labelWithPrefix,
@@ -117,7 +117,7 @@ export const reduceFields = ({
: pathPrefix
reduced.push(
...reduceFields({
...reduceFieldsToOptions({
fields: field.fields,
i18n,
labelPrefix: labelWithPrefix,
@@ -126,7 +126,7 @@ export const reduceFields = ({
)
} else {
reduced.push(
...reduceFields({
...reduceFieldsToOptions({
fields: field.fields,
i18n,
labelPrefix: labelWithPrefix,
@@ -155,7 +155,7 @@ export const reduceFields = ({
: pathPrefix
reduced.push(
...reduceFields({
...reduceFieldsToOptions({
fields: field.fields,
i18n,
labelPrefix: labelWithPrefix,

View File

@@ -8,6 +8,7 @@ import type {
ColumnPreference,
Field,
ImportMap,
ListQuery,
PaginatedDocs,
Payload,
SanitizedCollectionConfig,
@@ -21,9 +22,12 @@ import type { BuildColumnStateArgs } from '../providers/TableColumns/buildColumn
import { RenderServerComponent } from '../elements/RenderServerComponent/index.js'
import {
GroupByHeader,
GroupByPageControls,
OrderableTable,
Pill,
SelectAll,
SelectionProvider,
SelectRow,
SortHeader,
SortRow,
@@ -66,11 +70,16 @@ export const renderTable = ({
collections,
columns: columnsFromArgs,
customCellProps,
docs,
data,
enableRowSelections,
groupByFieldPath,
groupByValue,
heading,
i18n,
key = 'table',
orderableFieldName,
payload,
query,
renderRowTypes,
tableAppearance,
useAsTitle,
@@ -81,12 +90,17 @@ export const renderTable = ({
collections?: string[]
columns?: CollectionPreferences['columns']
customCellProps?: Record<string, unknown>
docs: PaginatedDocs['docs']
data: PaginatedDocs
drawerSlug?: string
enableRowSelections: boolean
groupByFieldPath?: string
groupByValue?: string
heading?: string
i18n: I18nClient
key?: string
orderableFieldName: string
payload: Payload
query?: ListQuery
renderRowTypes?: boolean
tableAppearance?: 'condensed' | 'default'
useAsTitle: CollectionConfig['admin']['useAsTitle']
@@ -101,6 +115,8 @@ export const renderTable = ({
let serverFields: Field[] = collectionConfig?.fields || []
const isPolymorphic = collections
const isGroupingBy = Boolean(collectionConfig?.admin?.groupBy && query?.groupBy)
if (isPolymorphic) {
clientFields = []
serverFields = []
@@ -176,14 +192,14 @@ export const renderTable = ({
...sharedArgs,
collectionSlug: undefined,
dataType: 'polymorphic',
docs,
docs: data.docs,
})
} else {
columnState = buildColumnState({
...sharedArgs,
collectionSlug: clientCollectionConfig.slug,
dataType: 'monomorphic',
docs,
docs: data.docs,
})
}
@@ -200,7 +216,7 @@ export const renderTable = ({
hidden: true,
},
Heading: i18n.t('version:type'),
renderedCells: docs.map((doc, i) => (
renderedCells: data.docs.map((doc, i) => (
<Pill key={i} size="small">
{getTranslation(
collections
@@ -224,15 +240,49 @@ export const renderTable = ({
hidden: true,
},
Heading: <SelectAll />,
renderedCells: docs.map((_, i) => <SelectRow key={i} rowData={docs[i]} />),
renderedCells: data.docs.map((_, i) => <SelectRow key={i} rowData={data.docs[i]} />),
} as Column)
}
if (isGroupingBy) {
return {
columnState,
// key is required since Next.js 15.2.0 to prevent React key error
Table: (
<div
className={['table-wrap', groupByValue !== undefined && `table-wrap--group-by`]
.filter(Boolean)
.join(' ')}
key={key}
>
<SelectionProvider docs={data.docs} totalDocs={data.totalDocs}>
<GroupByHeader
collectionConfig={clientCollectionConfig}
groupByFieldPath={groupByFieldPath}
groupByValue={groupByValue}
heading={heading}
/>
<Table appearance={tableAppearance} columns={columnsToUse} data={data.docs} />
<GroupByPageControls
collectionConfig={clientCollectionConfig}
data={data}
groupByValue={groupByValue}
/>
</SelectionProvider>
</div>
),
}
}
if (!orderableFieldName) {
return {
columnState,
// key is required since Next.js 15.2.0 to prevent React key error
Table: <Table appearance={tableAppearance} columns={columnsToUse} data={docs} key="table" />,
Table: (
<div className="table-wrap" key={key}>
<Table appearance={tableAppearance} columns={columnsToUse} data={data.docs} />
</div>
),
}
}
@@ -246,20 +296,21 @@ export const renderTable = ({
hidden: true,
},
Heading: <SortHeader />,
renderedCells: docs.map((_, i) => <SortRow key={i} />),
renderedCells: data.docs.map((_, i) => <SortRow key={i} />),
} as Column)
return {
columnState,
// key is required since Next.js 15.2.0 to prevent React key error
Table: (
<OrderableTable
appearance={tableAppearance}
collection={clientCollectionConfig}
columns={columnsToUse}
data={docs}
key="table"
/>
<div className="table-wrap" key={key}>
<OrderableTable
appearance={tableAppearance}
collection={clientCollectionConfig}
columns={columnsToUse}
data={data.docs}
/>
</div>
),
}
}

View File

@@ -49,6 +49,7 @@ export const ListSelection: React.FC<ListSelectionProps> = ({
getSelectedItems,
moveToFolder,
} = useFolder()
const { clearRouteCache } = useRouteCache()
const { config } = useConfig()
const { t } = useTranslation()

View File

@@ -0,0 +1,17 @@
@import '../../../scss/styles.scss';
@layer payload-default {
.group-by-header {
display: flex;
gap: var(--base);
&__heading {
margin: 0;
flex-grow: 1;
}
.list-selection__actions button {
margin: 0;
}
}
}

View File

@@ -0,0 +1,31 @@
import type { ClientCollectionConfig } from 'payload'
import React from 'react'
import { ListSelection } from '../ListSelection/index.js'
import './index.scss'
const baseClass = 'group-by-header'
export const GroupByHeader: React.FC<{
collectionConfig?: ClientCollectionConfig
groupByFieldPath: string
groupByValue: string
heading: string
}> = ({ collectionConfig, groupByFieldPath, groupByValue, heading }) => {
return (
<header className={baseClass}>
<h4 className={`${baseClass}__heading`}>{heading}</h4>
<ListSelection
collectionConfig={collectionConfig}
label={heading}
modalPrefix={groupByValue}
where={{
[groupByFieldPath]: {
equals: groupByValue,
},
}}
/>
</header>
)
}

View File

@@ -15,6 +15,7 @@ import {
ListCreateNewButton,
} from '../../../elements/ListHeader/TitleActions/index.js'
import { useConfig } from '../../../providers/Config/index.js'
import { useListQuery } from '../../../providers/ListQuery/index.js'
import { ListSelection } from '../ListSelection/index.js'
import './index.scss'
@@ -63,6 +64,7 @@ export const CollectionListHeader: React.FC<ListHeaderProps> = ({
}) => {
const { config, getEntityConfig } = useConfig()
const { drawerSlug, isInDrawer, selectedOption } = useListDrawerContext()
const { isGroupingBy } = useListQuery()
if (isInDrawer) {
return (
@@ -98,13 +100,14 @@ export const CollectionListHeader: React.FC<ListHeaderProps> = ({
return (
<ListHeader
Actions={[
!smallBreak && (
!smallBreak && !isGroupingBy && (
<ListSelection
collectionConfig={collectionConfig}
disableBulkDelete={disableBulkDelete}
disableBulkEdit={disableBulkEdit}
key="list-selection"
label={getTranslation(collectionConfig?.labels?.plural, i18n)}
showSelectAllAcrossPages={!isGroupingBy}
/>
),
collectionConfig.folders && config.folders && (

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import type { ClientCollectionConfig, Where } from 'payload'
import React, { Fragment, useCallback } from 'react'
@@ -16,6 +16,9 @@ export type ListSelectionProps = {
disableBulkDelete?: boolean
disableBulkEdit?: boolean
label: string
modalPrefix?: string
showSelectAllAcrossPages?: boolean
where?: Where
}
export const ListSelection: React.FC<ListSelectionProps> = ({
@@ -23,11 +26,14 @@ export const ListSelection: React.FC<ListSelectionProps> = ({
disableBulkDelete,
disableBulkEdit,
label,
modalPrefix,
showSelectAllAcrossPages = true,
where,
}) => {
const { count, getSelectedIds, selectAll, toggleAll, totalDocs } = useSelection()
const { count, selectAll, selectedIDs, toggleAll, totalDocs } = useSelection()
const { t } = useTranslation()
const onActionSuccess = useCallback(() => toggleAll(false), [toggleAll])
const onActionSuccess = useCallback(() => toggleAll(), [toggleAll])
if (count === 0) {
return null
@@ -37,7 +43,9 @@ export const ListSelection: React.FC<ListSelectionProps> = ({
<ListSelection_v4
count={count}
ListActions={[
selectAll !== SelectAllStatus.AllAvailable && count < totalDocs ? (
selectAll !== SelectAllStatus.AllAvailable &&
count < totalDocs &&
showSelectAllAcrossPages !== false ? (
<ListSelectionButton
aria-label={t('general:selectAll', { count: `(${totalDocs})`, label })}
id="select-all-across-pages"
@@ -54,27 +62,35 @@ export const ListSelection: React.FC<ListSelectionProps> = ({
<EditMany_v4
collection={collectionConfig}
count={count}
ids={getSelectedIds()}
ids={selectedIDs}
modalPrefix={modalPrefix}
onSuccess={onActionSuccess}
selectAll={selectAll === SelectAllStatus.AllAvailable}
where={where}
/>
<PublishMany_v4
collection={collectionConfig}
count={count}
ids={getSelectedIds()}
ids={selectedIDs}
modalPrefix={modalPrefix}
onSuccess={onActionSuccess}
selectAll={selectAll === SelectAllStatus.AllAvailable}
where={where}
/>
<UnpublishMany_v4
collection={collectionConfig}
count={count}
ids={getSelectedIds()}
ids={selectedIDs}
modalPrefix={modalPrefix}
onSuccess={onActionSuccess}
selectAll={selectAll === SelectAllStatus.AllAvailable}
where={where}
/>
</Fragment>
),
!disableBulkDelete && <DeleteMany collection={collectionConfig} key="bulk-delete" />,
!disableBulkDelete && (
<DeleteMany collection={collectionConfig} key="bulk-delete" modalPrefix={modalPrefix} />
),
].filter(Boolean)}
/>
)

View File

@@ -22,6 +22,16 @@
flex-basis: 100%;
}
&__tables {
.table-wrap:not(:last-child) {
margin-bottom: calc(var(--base) * 2);
}
.table-wrap--group-by:first-child {
margin-top: calc(var(--base) * 2);
}
}
.table {
table {
width: 100%;
@@ -62,27 +72,6 @@
}
}
&__page-controls {
width: 100%;
display: flex;
align-items: center;
}
.paginator {
margin-bottom: 0;
}
&__page-info {
[dir='ltr'] & {
margin-right: base(1);
margin-left: auto;
}
[dir='rtl'] & {
margin-left: base(1);
margin-right: auto;
}
}
&__list-selection {
position: fixed;
bottom: 0;
@@ -144,6 +133,7 @@
// this is to visually indicate overflowing content
.table {
display: flex;
flex-direction: column;
width: calc(100% + calc(var(--gutter-h) * 2));
max-width: unset;
left: calc(var(--gutter-h) * -1);
@@ -156,23 +146,10 @@
padding-right: var(--gutter-h);
}
}
&__page-controls {
flex-wrap: wrap;
}
&__page-info {
margin-left: 0;
}
.paginator {
width: 100%;
margin-bottom: $baseline;
}
}
@include small-break {
margin-bottom: base(2.4);
margin-bottom: base(4);
}
}
}

View File

@@ -4,8 +4,8 @@ import type { ListViewClientProps } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { useRouter } from 'next/navigation.js'
import { formatFilesize, isNumber } from 'payload/shared'
import React, { Fragment, useEffect, useState } from 'react'
import { formatFilesize } from 'payload/shared'
import React, { Fragment, useEffect } from 'react'
import { useBulkUpload } from '../../elements/BulkUpload/index.js'
import { Button } from '../../elements/Button/index.js'
@@ -13,13 +13,14 @@ import { Gutter } from '../../elements/Gutter/index.js'
import { ListControls } from '../../elements/ListControls/index.js'
import { useListDrawerContext } from '../../elements/ListDrawer/Provider.js'
import { useModal } from '../../elements/Modal/index.js'
import { Pagination } from '../../elements/Pagination/index.js'
import { PerPage } from '../../elements/PerPage/index.js'
import { PageControls } from '../../elements/PageControls/index.js'
import { RenderCustomComponent } from '../../elements/RenderCustomComponent/index.js'
import { SelectMany } from '../../elements/SelectMany/index.js'
import { useStepNav } from '../../elements/StepNav/index.js'
import { StickyToolbar } from '../../elements/StickyToolbar/index.js'
import { RelationshipProvider } from '../../elements/Table/RelationshipProvider/index.js'
import { ViewDescription } from '../../elements/ViewDescription/index.js'
import { useControllableState } from '../../hooks/useControllableState.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useConfig } from '../../providers/Config/index.js'
import { useListQuery } from '../../providers/ListQuery/index.js'
@@ -27,8 +28,8 @@ import { SelectionProvider } from '../../providers/Selection/index.js'
import { TableColumnsProvider } from '../../providers/TableColumns/index.js'
import { useTranslation } from '../../providers/Translation/index.js'
import { useWindowInfo } from '../../providers/WindowInfo/index.js'
import { ListSelection } from '../../views/List/ListSelection/index.js'
import { CollectionListHeader } from './ListHeader/index.js'
import { ListSelection } from './ListSelection/index.js'
import './index.scss'
const baseClass = 'collection-list'
@@ -57,7 +58,7 @@ export function DefaultListView(props: ListViewClientProps) {
Table: InitialTable,
} = props
const [Table, setTable] = useState(InitialTable)
const [Table] = useControllableState(InitialTable)
const { allowCreate, createNewDrawerSlug, isInDrawer, onBulkSelect } = useListDrawerContext()
@@ -66,24 +67,12 @@ export function DefaultListView(props: ListViewClientProps) {
? allowCreate && hasCreatePermissionFromProps
: hasCreatePermissionFromProps
useEffect(() => {
if (InitialTable) {
setTable(InitialTable)
}
}, [InitialTable])
const { user } = useAuth()
const { getEntityConfig } = useConfig()
const router = useRouter()
const {
data,
defaultLimit: initialLimit,
handlePageChange,
handlePerPageChange,
query,
} = useListQuery()
const { data, isGroupingBy } = useListQuery()
const { openModal } = useModal()
const { drawerSlug: bulkUploadDrawerSlug, setCollectionSlug, setOnSuccess } = useBulkUpload()
@@ -113,9 +102,9 @@ export function DefaultListView(props: ListViewClientProps) {
}
})
} else {
return data.docs
return data?.docs
}
}, [data.docs, isUploadCollection])
}, [data?.docs, isUploadCollection])
const openBulkUpload = React.useCallback(() => {
setCollectionSlug(collectionSlug)
@@ -137,7 +126,7 @@ export function DefaultListView(props: ListViewClientProps) {
<Fragment>
<TableColumnsProvider collectionSlug={collectionSlug} columnState={columnState}>
<div className={`${baseClass} ${baseClass}--${collectionSlug}`}>
<SelectionProvider docs={docs} totalDocs={data.totalDocs} user={user}>
<SelectionProvider docs={docs} totalDocs={data?.totalDocs}>
{BeforeList}
<Gutter className={`${baseClass}__wrap`}>
<CollectionListHeader
@@ -185,8 +174,12 @@ export function DefaultListView(props: ListViewClientProps) {
resolvedFilterOptions={resolvedFilterOptions}
/>
{BeforeListTable}
{docs.length > 0 && <RelationshipProvider>{Table}</RelationshipProvider>}
{docs.length === 0 && (
{docs?.length > 0 && (
<div className={`${baseClass}__tables`}>
<RelationshipProvider>{Table}</RelationshipProvider>
</div>
)}
{docs?.length === 0 && (
<div className={`${baseClass}__no-results`}>
<p>
{i18n.t('general:noResults', { label: getTranslation(labels?.plural, i18n) })}
@@ -211,63 +204,44 @@ export function DefaultListView(props: ListViewClientProps) {
</div>
)}
{AfterListTable}
{docs.length > 0 && (
<div className={`${baseClass}__page-controls`}>
<Pagination
hasNextPage={data.hasNextPage}
hasPrevPage={data.hasPrevPage}
limit={data.limit}
nextPage={data.nextPage}
numberOfNeighbors={1}
onChange={(page) => void handlePageChange(page)}
page={data.page}
prevPage={data.prevPage}
totalPages={data.totalPages}
/>
{data.totalDocs > 0 && (
<Fragment>
<div className={`${baseClass}__page-info`}>
{data.page * data.limit - (data.limit - 1)}-
{data.totalPages > 1 && data.totalPages !== data.page
? data.limit * data.page
: data.totalDocs}{' '}
{i18n.t('general:of')} {data.totalDocs}
</div>
<PerPage
handleChange={(limit) => void handlePerPageChange(limit)}
limit={isNumber(query?.limit) ? Number(query.limit) : initialLimit}
limits={collectionConfig?.admin?.pagination?.limits}
resetPage={data.totalDocs <= data.pagingCounter}
/>
{smallBreak && (
<div className={`${baseClass}__list-selection`}>
<ListSelection
collectionConfig={collectionConfig}
disableBulkDelete={disableBulkDelete}
disableBulkEdit={disableBulkEdit}
label={getTranslation(collectionConfig.labels.plural, i18n)}
/>
<div className={`${baseClass}__list-selection-actions`}>
{enableRowSelections && typeof onBulkSelect === 'function'
? beforeActions
? [
...beforeActions,
<SelectMany key="select-many" onClick={onBulkSelect} />,
]
: [<SelectMany key="select-many" onClick={onBulkSelect} />]
: beforeActions}
</div>
{docs?.length > 0 && !isGroupingBy && (
<PageControls
AfterPageControls={
smallBreak ? (
<div className={`${baseClass}__list-selection`}>
<ListSelection
collectionConfig={collectionConfig}
disableBulkDelete={disableBulkDelete}
disableBulkEdit={disableBulkEdit}
label={getTranslation(collectionConfig.labels.plural, i18n)}
showSelectAllAcrossPages={!isGroupingBy}
/>
<div className={`${baseClass}__list-selection-actions`}>
{enableRowSelections && typeof onBulkSelect === 'function'
? beforeActions
? [
...beforeActions,
<SelectMany key="select-many" onClick={onBulkSelect} />,
]
: [<SelectMany key="select-many" onClick={onBulkSelect} />]
: beforeActions}
</div>
)}
</Fragment>
)}
</div>
</div>
) : null
}
collectionConfig={collectionConfig}
/>
)}
</Gutter>
{AfterList}
</SelectionProvider>
</div>
</TableColumnsProvider>
{docs?.length > 0 && isGroupingBy && data.totalPages > 1 && (
<StickyToolbar>
<PageControls collectionConfig={collectionConfig} />
</StickyToolbar>
)}
</Fragment>
)
}

View File

@@ -35,10 +35,12 @@ let payload: PayloadTestSDK<Config>
import { devUser } from 'credentials.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { goToNextPage, goToPreviousPage } from 'helpers/e2e/goToNextPage.js'
import { goToFirstCell } from 'helpers/e2e/navigateToDoc.js'
import { openListColumns } from 'helpers/e2e/openListColumns.js'
import { openListFilters } from 'helpers/e2e/openListFilters.js'
import { deletePreferences } from 'helpers/e2e/preferences.js'
import { sortColumn } from 'helpers/e2e/sortColumn.js'
import { toggleColumn, waitForColumnInURL } from 'helpers/e2e/toggleColumn.js'
import { openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import { closeListDrawer } from 'helpers/e2e/toggleListDrawer.js'
@@ -630,7 +632,7 @@ describe('List View', () => {
const tableItems = page.locator(tableRowLocator)
await expect(tableItems).toHaveCount(5)
await expect(page.locator('.collection-list__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.per-page')).toContainText('Per Page: 5')
await page.goto(`${postsUrl.list}?limit=5&page=2`)
@@ -642,7 +644,7 @@ describe('List View', () => {
})
await page.waitForURL(new RegExp(`${postsUrl.list}\\?limit=5&page=1`))
await expect(page.locator('.collection-list__page-info')).toHaveText('1-3 of 3')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-3 of 3')
})
test('should reset filter values for every additional filter', async () => {
@@ -1355,13 +1357,13 @@ describe('List View', () => {
await page.reload()
await expect(page.locator(tableRowLocator)).toHaveCount(5)
await expect(page.locator('.collection-list__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-5 of 6')
await expect(page.locator('.per-page')).toContainText('Per Page: 5')
await page.locator('.paginator button').nth(1).click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2')
await goToNextPage(page)
await expect(page.locator(tableRowLocator)).toHaveCount(1)
await page.locator('.paginator button').nth(0).click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=1')
await goToPreviousPage(page)
await expect(page.locator(tableRowLocator)).toHaveCount(5)
})
@@ -1375,7 +1377,7 @@ describe('List View', () => {
await page.reload()
const tableItems = page.locator(tableRowLocator)
await expect(tableItems).toHaveCount(5)
await expect(page.locator('.collection-list__page-info')).toHaveText('1-5 of 16')
await expect(page.locator('.page-controls__page-info')).toHaveText('1-5 of 16')
await expect(page.locator('.per-page')).toContainText('Per Page: 5')
await page.locator('.per-page .popup-button').click()
@@ -1387,11 +1389,11 @@ describe('List View', () => {
await expect(tableItems).toHaveCount(15)
await expect(page.locator('.per-page .per-page__base-button')).toContainText('Per Page: 15')
await page.locator('.paginator button').nth(1).click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2')
await goToNextPage(page)
await expect(tableItems).toHaveCount(1)
await expect(page.locator('.per-page')).toContainText('Per Page: 15') // ensure this hasn't changed
await expect(page.locator('.collection-list__page-info')).toHaveText('16-16 of 16')
await expect(page.locator('.page-controls__page-info')).toHaveText('16-16 of 16')
})
})
@@ -1410,17 +1412,13 @@ describe('List View', () => {
test('should sort', async () => {
await page.reload()
const upChevron = page.locator('#heading-number .sort-column__asc')
const downChevron = page.locator('#heading-number .sort-column__desc')
await upChevron.click()
await page.waitForURL(/sort=number/)
await sortColumn(page, { fieldPath: 'number', fieldLabel: 'Number', targetState: 'asc' })
await expect(page.locator('.row-1 .cell-number')).toHaveText('1')
await expect(page.locator('.row-2 .cell-number')).toHaveText('2')
await downChevron.click()
await page.waitForURL(/sort=-number/)
await sortColumn(page, { fieldPath: 'number', fieldLabel: 'Number', targetState: 'desc' })
await expect(page.locator('.row-1 .cell-number')).toHaveText('2')
await expect(page.locator('.row-2 .cell-number')).toHaveText('1')
@@ -1434,25 +1432,31 @@ describe('List View', () => {
hasText: exactText('Named Group > Some Text Field'),
})
.click()
const upChevron = page.locator('#heading-namedGroup__someTextField .sort-column__asc')
const downChevron = page.locator('#heading-namedGroup__someTextField .sort-column__desc')
await upChevron.click()
await page.waitForURL(/sort=namedGroup.someTextField/)
await sortColumn(page, {
fieldPath: 'namedGroup.someTextField',
fieldLabel: 'Named Group > Some Text Field',
targetState: 'asc',
})
await expect(page.locator('.row-1 .cell-namedGroup__someTextField')).toHaveText(
'<No Some Text Field>',
)
await expect(page.locator('.row-2 .cell-namedGroup__someTextField')).toHaveText(
'nested group text field',
)
await downChevron.click()
await page.waitForURL(/sort=-namedGroup.someTextField/)
await sortColumn(page, {
fieldPath: 'namedGroup.someTextField',
fieldLabel: 'Named Group > Some Text Field',
targetState: 'desc',
})
await expect(page.locator('.row-1 .cell-namedGroup__someTextField')).toHaveText(
'nested group text field',
)
await expect(page.locator('.row-2 .cell-namedGroup__someTextField')).toHaveText(
'<No Some Text Field>',
)
@@ -1466,29 +1470,31 @@ describe('List View', () => {
hasText: exactText('Named Tab > Nested Text Field In Named Tab'),
})
.click()
const upChevron = page.locator(
'#heading-namedTab__nestedTextFieldInNamedTab .sort-column__asc',
)
const downChevron = page.locator(
'#heading-namedTab__nestedTextFieldInNamedTab .sort-column__desc',
)
await upChevron.click()
await page.waitForURL(/sort=namedTab.nestedTextFieldInNamedTab/)
await sortColumn(page, {
fieldPath: 'namedTab.nestedTextFieldInNamedTab',
fieldLabel: 'Named Tab > Nested Text Field In Named Tab',
targetState: 'asc',
})
await expect(page.locator('.row-1 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'<No Nested Text Field In Named Tab>',
)
await expect(page.locator('.row-2 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'nested text in named tab',
)
await downChevron.click()
await page.waitForURL(/sort=-namedTab.nestedTextFieldInNamedTab/)
await sortColumn(page, {
fieldPath: 'namedTab.nestedTextFieldInNamedTab',
fieldLabel: 'Named Tab > Nested Text Field In Named Tab',
targetState: 'desc',
})
await expect(page.locator('.row-1 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'nested text in named tab',
)
await expect(page.locator('.row-2 .cell-namedTab__nestedTextFieldInNamedTab')).toHaveText(
'<No Nested Text Field In Named Tab>',
)

View File

@@ -320,7 +320,7 @@ test.describe('Bulk Edit', () => {
await page.goto(postsUrl.list)
await expect(page.locator('.collection-list__page-info')).toContainText('1-5 of 6')
await expect(page.locator('.page-controls__page-info')).toContainText('1-5 of 6')
await page.locator('#search-filter-input').fill('Post')
await page.waitForURL(/search=Post/)

View File

@@ -4,6 +4,7 @@ import type { CollectionSlug } from 'payload'
import { expect, test } from '@playwright/test'
import { assertToastErrors } from 'helpers/assertToastErrors.js'
import { addListFilter } from 'helpers/e2e/addListFilter.js'
import { goToNextPage } from 'helpers/e2e/goToNextPage.js'
import { openDocControls } from 'helpers/e2e/openDocControls.js'
import { openCreateDocDrawer, openDocDrawer } from 'helpers/e2e/toggleDocDrawer.js'
import path from 'path'
@@ -739,9 +740,7 @@ describe('Relationship Field', () => {
const relationship = page.locator('.row-1 .cell-relationshipHasManyMultiple')
await expect(relationship).toHaveText(relationTwoDoc.id)
const paginator = page.locator('.clickable-arrow--right')
await paginator.click()
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).toContain('page=2')
await goToNextPage(page)
// check first doc on second page (should be different)
await expect(relationship).toContainText(relationOneDoc.id)

2
test/group-by/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/media
/media-gif

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
export const categoriesSlug = 'categories'
export const CategoriesCollection: CollectionConfig = {
slug: categoriesSlug,
admin: {
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,33 @@
import type { CollectionConfig } from 'payload'
export const mediaSlug = 'media'
export const MediaCollection: CollectionConfig = {
slug: mediaSlug,
access: {
create: () => true,
read: () => true,
},
fields: [],
upload: {
crop: true,
focalPoint: true,
imageSizes: [
{
name: 'thumbnail',
height: 200,
width: 200,
},
{
name: 'medium',
height: 800,
width: 800,
},
{
name: 'large',
height: 1200,
width: 1200,
},
],
},
}

Some files were not shown because too many files have changed in this diff Show More