From bccf6ab16f3562bbf2d9e26dc0e2d34d4a3cf732 Mon Sep 17 00:00:00 2001 From: Jacob Fletcher Date: Thu, 24 Jul 2025 14:00:52 -0400 Subject: [PATCH] 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 --- .github/workflows/main.yml | 2 + docs/admin/overview.mdx | 2 +- docs/configuration/collections.mdx | 1 + packages/next/src/views/List/handleGroupBy.ts | 199 + packages/next/src/views/List/index.tsx | 101 +- packages/payload/src/admin/functions/index.ts | 10 + packages/payload/src/admin/views/list.ts | 2 +- .../payload/src/collections/config/types.ts | 7 + packages/payload/src/index.ts | 1 - packages/payload/src/preferences/types.ts | 1 + .../utilities/transformColumnPreferences.ts | 4 + packages/translations/src/clientKeys.ts | 2 + packages/translations/src/languages/ar.ts | 2 + packages/translations/src/languages/az.ts | 3 + packages/translations/src/languages/bg.ts | 2 + packages/translations/src/languages/bnBd.ts | 3 + packages/translations/src/languages/bnIn.ts | 2 + packages/translations/src/languages/ca.ts | 2 + packages/translations/src/languages/cs.ts | 2 + packages/translations/src/languages/da.ts | 2 + packages/translations/src/languages/de.ts | 3 + packages/translations/src/languages/en.ts | 2 + packages/translations/src/languages/es.ts | 2 + packages/translations/src/languages/et.ts | 2 + packages/translations/src/languages/fa.ts | 2 + packages/translations/src/languages/fr.ts | 2 + packages/translations/src/languages/he.ts | 3 + packages/translations/src/languages/hr.ts | 2 + packages/translations/src/languages/hu.ts | 2 + packages/translations/src/languages/hy.ts | 3 + packages/translations/src/languages/it.ts | 2 + packages/translations/src/languages/ja.ts | 2 + packages/translations/src/languages/ko.ts | 3 + packages/translations/src/languages/lt.ts | 2 + packages/translations/src/languages/lv.ts | 3 + packages/translations/src/languages/my.ts | 2 + packages/translations/src/languages/nb.ts | 2 + packages/translations/src/languages/nl.ts | 2 + packages/translations/src/languages/pl.ts | 2 + packages/translations/src/languages/pt.ts | 2 + packages/translations/src/languages/ro.ts | 2 + packages/translations/src/languages/rs.ts | 2 + .../translations/src/languages/rsLatin.ts | 2 + packages/translations/src/languages/ru.ts | 2 + packages/translations/src/languages/sk.ts | 2 + packages/translations/src/languages/sl.ts | 2 + packages/translations/src/languages/sv.ts | 2 + packages/translations/src/languages/th.ts | 3 + packages/translations/src/languages/tr.ts | 2 + packages/translations/src/languages/uk.ts | 2 + packages/translations/src/languages/vi.ts | 2 + packages/translations/src/languages/zh.ts | 2 + packages/translations/src/languages/zhTw.ts | 2 + .../ui/src/elements/ColumnSelector/index.tsx | 2 +- packages/ui/src/elements/DeleteMany/index.tsx | 50 +- .../src/elements/EditMany/DrawerContent.tsx | 11 +- packages/ui/src/elements/EditMany/index.tsx | 23 +- .../ui/src/elements/GroupByBuilder/index.scss | 39 + .../ui/src/elements/GroupByBuilder/index.tsx | 144 + .../ui/src/elements/ListControls/index.scss | 6 +- .../ui/src/elements/ListControls/index.tsx | 43 +- .../PageControls/GroupByPageControls.tsx | 62 + .../ui/src/elements/PageControls/index.scss | 40 + .../ui/src/elements/PageControls/index.tsx | 94 + .../Pagination/ClickableArrow/index.scss | 6 +- .../ui/src/elements/Pagination/index.scss | 8 +- packages/ui/src/elements/Pagination/index.tsx | 2 +- .../elements/PublishMany/DrawerContent.tsx | 13 +- .../ui/src/elements/PublishMany/index.tsx | 19 +- packages/ui/src/elements/ReactSelect/types.ts | 1 + .../src/elements/RelationshipTable/index.tsx | 26 +- .../ui/src/elements/StickyToolbar/index.scss | 27 + .../ui/src/elements/StickyToolbar/index.tsx | 9 + .../ui/src/elements/Table/OrderableTable.tsx | 4 + packages/ui/src/elements/Table/index.tsx | 6 +- .../elements/UnpublishMany/DrawerContent.tsx | 8 +- .../ui/src/elements/UnpublishMany/index.tsx | 18 +- .../ui/src/elements/WhereBuilder/index.tsx | 8 +- packages/ui/src/exports/client/index.ts | 2 + packages/ui/src/fields/Select/Input.tsx | 3 + packages/ui/src/providers/ListQuery/index.tsx | 37 +- .../ui/src/providers/ListQuery/mergeQuery.ts | 31 +- packages/ui/src/providers/ListQuery/types.ts | 12 +- packages/ui/src/providers/Selection/index.tsx | 63 +- packages/ui/src/utilities/buildTableState.ts | 12 +- .../reduceFieldsToOptions.tsx} | 22 +- packages/ui/src/utilities/renderTable.tsx | 81 +- .../CollectionFolder/ListSelection/index.tsx | 1 + .../src/views/List/GroupByHeader/index.scss | 17 + .../ui/src/views/List/GroupByHeader/index.tsx | 31 + .../ui/src/views/List/ListHeader/index.tsx | 5 +- .../ui/src/views/List/ListSelection/index.tsx | 32 +- packages/ui/src/views/List/index.scss | 47 +- packages/ui/src/views/List/index.tsx | 122 +- test/admin/e2e/list-view/e2e.spec.ts | 72 +- test/bulk-edit/e2e.spec.ts | 2 +- test/fields-relationship/e2e.spec.ts | 5 +- test/group-by/.gitignore | 2 + test/group-by/collections/Categories/index.ts | 16 + test/group-by/collections/Media/index.ts | 33 + test/group-by/collections/Posts/index.ts | 48 + test/group-by/config.ts | 30 + test/group-by/e2e.spec.ts | 607 +++ test/group-by/payload-types.ts | 428 ++ test/group-by/schema.graphql | 4271 +++++++++++++++++ test/group-by/seed.ts | 84 + test/group-by/tsconfig.eslint.json | 13 + test/group-by/tsconfig.json | 3 + test/group-by/types.d.ts | 9 + test/helpers.ts | 6 +- test/helpers/e2e/goToNextPage.ts | 49 + test/helpers/e2e/groupBy.ts | 90 + test/helpers/e2e/openListFilters.ts | 8 +- test/helpers/e2e/sortColumn.ts | 36 + test/helpers/e2e/toggleListDrawer.ts | 3 + test/joins/e2e.spec.ts | 3 + test/joins/payload-types.ts | 2 + test/lexical/collections/RichText/e2e.spec.ts | 16 +- test/lexical/payload-types.ts | 14 + test/locked-documents/e2e.spec.ts | 11 +- test/query-presets/e2e.spec.ts | 3 +- test/query-presets/helpers/togglePreset.ts | 3 +- test/sort/payload-types.ts | 38 +- tsconfig.base.json | 114 +- 124 files changed, 7181 insertions(+), 447 deletions(-) create mode 100644 packages/next/src/views/List/handleGroupBy.ts create mode 100644 packages/ui/src/elements/GroupByBuilder/index.scss create mode 100644 packages/ui/src/elements/GroupByBuilder/index.tsx create mode 100644 packages/ui/src/elements/PageControls/GroupByPageControls.tsx create mode 100644 packages/ui/src/elements/PageControls/index.scss create mode 100644 packages/ui/src/elements/PageControls/index.tsx create mode 100644 packages/ui/src/elements/StickyToolbar/index.scss create mode 100644 packages/ui/src/elements/StickyToolbar/index.tsx rename packages/ui/src/{elements/WhereBuilder/reduceFields.tsx => utilities/reduceFieldsToOptions.tsx} (90%) create mode 100644 packages/ui/src/views/List/GroupByHeader/index.scss create mode 100644 packages/ui/src/views/List/GroupByHeader/index.tsx create mode 100644 test/group-by/.gitignore create mode 100644 test/group-by/collections/Categories/index.ts create mode 100644 test/group-by/collections/Media/index.ts create mode 100644 test/group-by/collections/Posts/index.ts create mode 100644 test/group-by/config.ts create mode 100644 test/group-by/e2e.spec.ts create mode 100644 test/group-by/payload-types.ts create mode 100644 test/group-by/schema.graphql create mode 100644 test/group-by/seed.ts create mode 100644 test/group-by/tsconfig.eslint.json create mode 100644 test/group-by/tsconfig.json create mode 100644 test/group-by/types.d.ts create mode 100644 test/helpers/e2e/goToNextPage.ts create mode 100644 test/helpers/e2e/groupBy.ts create mode 100644 test/helpers/e2e/sortColumn.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e10abad45..17c907c44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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 diff --git a/docs/admin/overview.mdx b/docs/admin/overview.mdx index 069357d58..30be42884 100644 --- a/docs/admin/overview.mdx +++ b/docs/admin/overview.mdx @@ -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' diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index f431c925f..c6a6e1ebd 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -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. | diff --git a/packages/next/src/views/List/handleGroupBy.ts b/packages/next/src/views/List/handleGroupBy.ts new file mode 100644 index 000000000..8d96e5d7e --- /dev/null +++ b/packages/next/src/views/List/handleGroupBy.ts @@ -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 + 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 = {} + 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)[i] = NewTable + } + }), + ) + + return { + columnState, + data, + Table, + } +} diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 59c0c3dfc..41dbbc208 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -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({ 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 { diff --git a/packages/payload/src/admin/functions/index.ts b/packages/payload/src/admin/functions/index.ts index 1d42c1cca..aaa1106fe 100644 --- a/packages/payload/src/admin/functions/index.ts +++ b/packages/payload/src/admin/functions/index.ts @@ -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 /* 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 diff --git a/packages/payload/src/admin/views/list.ts b/packages/payload/src/admin/views/list.ts index 7097e0bd4..6a3b320ac 100644 --- a/packages/payload/src/admin/views/list.ts +++ b/packages/payload/src/admin/views/list.ts @@ -17,7 +17,7 @@ export type ListViewSlots = { BeforeListTable?: React.ReactNode Description?: React.ReactNode listMenuItems?: React.ReactNode[] - Table: React.ReactNode + Table: React.ReactNode | React.ReactNode[] } /** diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 441471554..97c4004c8 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -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 + /** + * @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 */ diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index 55a520722..542f18c71 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -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, diff --git a/packages/payload/src/preferences/types.ts b/packages/payload/src/preferences/types.ts index 0e4a137a3..245fd2ad6 100644 --- a/packages/payload/src/preferences/types.ts +++ b/packages/payload/src/preferences/types.ts @@ -37,6 +37,7 @@ export type ColumnPreference = { export type CollectionPreferences = { columns?: ColumnPreference[] editViewType?: 'default' | 'live-preview' + groupBy?: string limit?: number preset?: DefaultDocumentIDType sort?: string diff --git a/packages/payload/src/utilities/transformColumnPreferences.ts b/packages/payload/src/utilities/transformColumnPreferences.ts index d0412df47..b6619c953 100644 --- a/packages/payload/src/utilities/transformColumnPreferences.ts +++ b/packages/payload/src/utilities/transformColumnPreferences.ts @@ -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 diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index f50aa54f8..71e84d9b6 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -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', diff --git a/packages/translations/src/languages/ar.ts b/packages/translations/src/languages/ar.ts index ce23c1780..300a89329 100644 --- a/packages/translations/src/languages/ar.ts +++ b/packages/translations/src/languages/ar.ts @@ -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: 'عنصر', diff --git a/packages/translations/src/languages/az.ts b/packages/translations/src/languages/az.ts index bc2ecb7ab..1efebc54d 100644 --- a/packages/translations/src/languages/az.ts +++ b/packages/translations/src/languages/az.ts @@ -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', diff --git a/packages/translations/src/languages/bg.ts b/packages/translations/src/languages/bg.ts index 308778b05..8f99debc9 100644 --- a/packages/translations/src/languages/bg.ts +++ b/packages/translations/src/languages/bg.ts @@ -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: 'артикул', diff --git a/packages/translations/src/languages/bnBd.ts b/packages/translations/src/languages/bnBd.ts index 9a41c809f..4a1b06d22 100644 --- a/packages/translations/src/languages/bnBd.ts +++ b/packages/translations/src/languages/bnBd.ts @@ -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: 'আইটেম', diff --git a/packages/translations/src/languages/bnIn.ts b/packages/translations/src/languages/bnIn.ts index 8c01eb2f7..0c527627b 100644 --- a/packages/translations/src/languages/bnIn.ts +++ b/packages/translations/src/languages/bnIn.ts @@ -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: 'আইটেম', diff --git a/packages/translations/src/languages/ca.ts b/packages/translations/src/languages/ca.ts index c3c2ecead..5d15a184d 100644 --- a/packages/translations/src/languages/ca.ts +++ b/packages/translations/src/languages/ca.ts @@ -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', diff --git a/packages/translations/src/languages/cs.ts b/packages/translations/src/languages/cs.ts index 7f8304f59..6259af351 100644 --- a/packages/translations/src/languages/cs.ts +++ b/packages/translations/src/languages/cs.ts @@ -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', diff --git a/packages/translations/src/languages/da.ts b/packages/translations/src/languages/da.ts index ec1ef4b6e..ba3279727 100644 --- a/packages/translations/src/languages/da.ts +++ b/packages/translations/src/languages/da.ts @@ -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', diff --git a/packages/translations/src/languages/de.ts b/packages/translations/src/languages/de.ts index ae924bb36..5adc81666 100644 --- a/packages/translations/src/languages/de.ts +++ b/packages/translations/src/languages/de.ts @@ -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', diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index e1600e45a..6e4116e7f 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -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', diff --git a/packages/translations/src/languages/es.ts b/packages/translations/src/languages/es.ts index d91a45c21..e61ad47f6 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -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', diff --git a/packages/translations/src/languages/et.ts b/packages/translations/src/languages/et.ts index 15c77ebea..f15b3adc5 100644 --- a/packages/translations/src/languages/et.ts +++ b/packages/translations/src/languages/et.ts @@ -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', diff --git a/packages/translations/src/languages/fa.ts b/packages/translations/src/languages/fa.ts index 1284b1d94..4b6d8db05 100644 --- a/packages/translations/src/languages/fa.ts +++ b/packages/translations/src/languages/fa.ts @@ -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: 'مورد', diff --git a/packages/translations/src/languages/fr.ts b/packages/translations/src/languages/fr.ts index c5eab55fd..926a9917b 100644 --- a/packages/translations/src/languages/fr.ts +++ b/packages/translations/src/languages/fr.ts @@ -237,6 +237,7 @@ export const frTranslations: DefaultTranslationsObject = { cancel: 'Annuler', changesNotSaved: 'Vos modifications n’ont 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', diff --git a/packages/translations/src/languages/he.ts b/packages/translations/src/languages/he.ts index f7a8d4ff9..394335db6 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -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: 'פריט', diff --git a/packages/translations/src/languages/hr.ts b/packages/translations/src/languages/hr.ts index 320217c8e..5f2b7d7db 100644 --- a/packages/translations/src/languages/hr.ts +++ b/packages/translations/src/languages/hr.ts @@ -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', diff --git a/packages/translations/src/languages/hu.ts b/packages/translations/src/languages/hu.ts index 8aaa81144..7cad548bb 100644 --- a/packages/translations/src/languages/hu.ts +++ b/packages/translations/src/languages/hu.ts @@ -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', diff --git a/packages/translations/src/languages/hy.ts b/packages/translations/src/languages/hy.ts index 704b20d8e..c35a99517 100644 --- a/packages/translations/src/languages/hy.ts +++ b/packages/translations/src/languages/hy.ts @@ -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: 'տարր', diff --git a/packages/translations/src/languages/it.ts b/packages/translations/src/languages/it.ts index 3a51ef09b..86e0d42fb 100644 --- a/packages/translations/src/languages/it.ts +++ b/packages/translations/src/languages/it.ts @@ -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', diff --git a/packages/translations/src/languages/ja.ts b/packages/translations/src/languages/ja.ts index 024cf4e1f..128252db0 100644 --- a/packages/translations/src/languages/ja.ts +++ b/packages/translations/src/languages/ja.ts @@ -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: 'アイテム', diff --git a/packages/translations/src/languages/ko.ts b/packages/translations/src/languages/ko.ts index 0d5af0445..f093b2d0c 100644 --- a/packages/translations/src/languages/ko.ts +++ b/packages/translations/src/languages/ko.ts @@ -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: '항목', diff --git a/packages/translations/src/languages/lt.ts b/packages/translations/src/languages/lt.ts index 94048058c..ac2afd6c6 100644 --- a/packages/translations/src/languages/lt.ts +++ b/packages/translations/src/languages/lt.ts @@ -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', diff --git a/packages/translations/src/languages/lv.ts b/packages/translations/src/languages/lv.ts index 0dcd97368..62c907615 100644 --- a/packages/translations/src/languages/lv.ts +++ b/packages/translations/src/languages/lv.ts @@ -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', diff --git a/packages/translations/src/languages/my.ts b/packages/translations/src/languages/my.ts index 78c87fa72..ec822f835 100644 --- a/packages/translations/src/languages/my.ts +++ b/packages/translations/src/languages/my.ts @@ -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', diff --git a/packages/translations/src/languages/nb.ts b/packages/translations/src/languages/nb.ts index 291b85b6f..c454ab3e7 100644 --- a/packages/translations/src/languages/nb.ts +++ b/packages/translations/src/languages/nb.ts @@ -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', diff --git a/packages/translations/src/languages/nl.ts b/packages/translations/src/languages/nl.ts index 1ba7d51a2..33ab43623 100644 --- a/packages/translations/src/languages/nl.ts +++ b/packages/translations/src/languages/nl.ts @@ -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', diff --git a/packages/translations/src/languages/pl.ts b/packages/translations/src/languages/pl.ts index 1e60b6ac7..dd5d4ab4f 100644 --- a/packages/translations/src/languages/pl.ts +++ b/packages/translations/src/languages/pl.ts @@ -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', diff --git a/packages/translations/src/languages/pt.ts b/packages/translations/src/languages/pt.ts index ac01e47c0..6a26c458b 100644 --- a/packages/translations/src/languages/pt.ts +++ b/packages/translations/src/languages/pt.ts @@ -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', diff --git a/packages/translations/src/languages/ro.ts b/packages/translations/src/languages/ro.ts index 34bae916f..8d58ed276 100644 --- a/packages/translations/src/languages/ro.ts +++ b/packages/translations/src/languages/ro.ts @@ -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', diff --git a/packages/translations/src/languages/rs.ts b/packages/translations/src/languages/rs.ts index 1f0701c3f..d8a2e3824 100644 --- a/packages/translations/src/languages/rs.ts +++ b/packages/translations/src/languages/rs.ts @@ -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', diff --git a/packages/translations/src/languages/rsLatin.ts b/packages/translations/src/languages/rsLatin.ts index 2ae83c93d..1207d5cfb 100644 --- a/packages/translations/src/languages/rsLatin.ts +++ b/packages/translations/src/languages/rsLatin.ts @@ -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', diff --git a/packages/translations/src/languages/ru.ts b/packages/translations/src/languages/ru.ts index b23b2ef9f..4eba3f49c 100644 --- a/packages/translations/src/languages/ru.ts +++ b/packages/translations/src/languages/ru.ts @@ -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: 'предмет', diff --git a/packages/translations/src/languages/sk.ts b/packages/translations/src/languages/sk.ts index 4c24b250d..13a6c5d5a 100644 --- a/packages/translations/src/languages/sk.ts +++ b/packages/translations/src/languages/sk.ts @@ -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', diff --git a/packages/translations/src/languages/sl.ts b/packages/translations/src/languages/sl.ts index 02e046a58..6880c5a6e 100644 --- a/packages/translations/src/languages/sl.ts +++ b/packages/translations/src/languages/sl.ts @@ -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', diff --git a/packages/translations/src/languages/sv.ts b/packages/translations/src/languages/sv.ts index caef27df3..bad3e2435 100644 --- a/packages/translations/src/languages/sv.ts +++ b/packages/translations/src/languages/sv.ts @@ -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', diff --git a/packages/translations/src/languages/th.ts b/packages/translations/src/languages/th.ts index 41cb9878b..3346be921 100644 --- a/packages/translations/src/languages/th.ts +++ b/packages/translations/src/languages/th.ts @@ -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: 'รายการ', diff --git a/packages/translations/src/languages/tr.ts b/packages/translations/src/languages/tr.ts index 1daaae292..b9d955160 100644 --- a/packages/translations/src/languages/tr.ts +++ b/packages/translations/src/languages/tr.ts @@ -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', diff --git a/packages/translations/src/languages/uk.ts b/packages/translations/src/languages/uk.ts index eb33c1daa..1d84f1eb7 100644 --- a/packages/translations/src/languages/uk.ts +++ b/packages/translations/src/languages/uk.ts @@ -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: 'предмет', diff --git a/packages/translations/src/languages/vi.ts b/packages/translations/src/languages/vi.ts index 7f747ef15..1af0493b6 100644 --- a/packages/translations/src/languages/vi.ts +++ b/packages/translations/src/languages/vi.ts @@ -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', diff --git a/packages/translations/src/languages/zh.ts b/packages/translations/src/languages/zh.ts index 84a477ba7..b8849294c 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -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: '条目', diff --git a/packages/translations/src/languages/zhTw.ts b/packages/translations/src/languages/zhTw.ts index e659462f6..451bc4fd2 100644 --- a/packages/translations/src/languages/zhTw.ts +++ b/packages/translations/src/languages/zhTw.ts @@ -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: '物品', diff --git a/packages/ui/src/elements/ColumnSelector/index.tsx b/packages/ui/src/elements/ColumnSelector/index.tsx index caffabe9d..f208d87e4 100644 --- a/packages/ui/src/elements/ColumnSelector/index.tsx +++ b/packages/ui/src/elements/ColumnSelector/index.tsx @@ -21,7 +21,7 @@ export const ColumnSelector: React.FC = ({ collectionSlug }) => { const filteredColumns = useMemo( () => - columns.filter( + columns?.filter( (col) => !(fieldIsHiddenOrDisabled(col.field) && !fieldIsID(col.field)) && !col?.field?.admin?.disableListColumn, diff --git a/packages/ui/src/elements/DeleteMany/index.tsx b/packages/ui/src/elements/DeleteMany/index.tsx index 561cf3964..0c1a6d63c 100644 --- a/packages/ui/src/elements/DeleteMany/index.tsx +++ b/packages/ui/src/elements/DeleteMany/index.tsx @@ -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) => { - 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) => { 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 ( = (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) { diff --git a/packages/ui/src/elements/EditMany/DrawerContent.tsx b/packages/ui/src/elements/EditMany/DrawerContent.tsx index 74b6c50ff..1a072db26 100644 --- a/packages/ui/src/elements/EditMany/DrawerContent.tsx +++ b/packages/ui/src/elements/EditMany/DrawerContent.tsx @@ -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 = (props) => { const { collection, @@ -151,6 +153,7 @@ export const EditManyDrawerContent: React.FC = (prop selectAll, selectedFields, setSelectedFields, + where, } = props const { permissions, user } = useAuth() @@ -220,6 +223,10 @@ export const EditManyDrawerContent: React.FC = (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 = (prop whereConstraints.push( (parseSearchParams(searchParams)?.where as Where) || { id: { - exists: true, + not_equals: '', }, }, ) @@ -254,7 +261,7 @@ export const EditManyDrawerContent: React.FC = (prop }, { addQueryPrefix: true }, ) - }, [collection, searchParams, selectAll, ids, locale]) + }, [collection, searchParams, selectAll, ids, locale, where]) const onSuccess = () => { router.replace( diff --git a/packages/ui/src/elements/EditMany/index.tsx b/packages/ui/src/elements/EditMany/index.tsx index b9aa3a11a..6f1220bdc 100644 --- a/packages/ui/src/elements/EditMany/index.tsx +++ b/packages/ui/src/elements/EditMany/index.tsx @@ -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 = (props) => { - const { count, selectAll, selected, toggleAll } = useSelection() + const { count, selectAll, selectedIDs, toggleAll } = useSelection() + return ( 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 +> = ({ 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} /> diff --git a/packages/ui/src/elements/GroupByBuilder/index.scss b/packages/ui/src/elements/GroupByBuilder/index.scss new file mode 100644 index 000000000..f05f1c3fc --- /dev/null +++ b/packages/ui/src/elements/GroupByBuilder/index.scss @@ -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%; + } + } + } +} diff --git a/packages/ui/src/elements/GroupByBuilder/index.tsx b/packages/ui/src/elements/GroupByBuilder/index.tsx new file mode 100644 index 000000000..390666cb9 --- /dev/null +++ b/packages/ui/src/elements/GroupByBuilder/index.tsx @@ -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 = ({ 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 ( +
+
+

+ {t('general:groupByLabel', { + label: '', + })} +

+ {query.groupBy && ( + + )} +
+
+ + ((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 || '', + }} + /> + { + 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'}` + : '' + } + /> +
+
+ ) +} diff --git a/packages/ui/src/elements/ListControls/index.scss b/packages/ui/src/elements/ListControls/index.scss index 6443e5f16..86c2009f0 100644 --- a/packages/ui/src/elements/ListControls/index.scss +++ b/packages/ui/src/elements/ListControls/index.scss @@ -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; } } diff --git a/packages/ui/src/elements/ListControls/index.tsx b/packages/ui/src/elements/ListControls/index.tsx index a5adc88ce..ebdb31d6a 100644 --- a/packages/ui/src/elements/ListControls/index.tsx +++ b/packages/ui/src/elements/ListControls/index.tsx @@ -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 = (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 = (props) => { let listMenuItems: React.ReactNode[] = listMenuItemsFromProps if ( - collectionConfig?.enableQueryPresets && + collectionConfig.enableQueryPresets && !disableQueryPresets && queryPresetMenuItems?.length > 0 ) { @@ -160,7 +162,6 @@ export const ListControls: React.FC = (props) => { @@ -176,6 +177,7 @@ export const ListControls: React.FC = (props) => { aria-expanded={visibleDrawer === 'columns'} className={`${baseClass}__toggle-columns`} icon={} + id="toggle-columns" onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined) } @@ -191,6 +193,7 @@ export const ListControls: React.FC = (props) => { aria-expanded={visibleDrawer === 'where'} className={`${baseClass}__toggle-where`} icon={} + id="toggle-list-filters" onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)} pillStyle="light" size="small" @@ -218,6 +221,24 @@ export const ListControls: React.FC = (props) => { resetPreset={resetPreset} /> )} + {collectionConfig.admin.groupBy && ( + } + id="toggle-group-by" + onClick={() => + setVisibleDrawer(visibleDrawer !== 'group-by' ? 'group-by' : undefined) + } + pillStyle="light" + size="small" + > + {t('general:groupByLabel', { + label: '', + })} + + )} {listMenuItems && Array.isArray(listMenuItems) && listMenuItems.length > 0 && ( } @@ -250,13 +271,25 @@ export const ListControls: React.FC = (props) => { id={`${baseClass}-where`} > + {collectionConfig.admin.groupBy && ( + + + + )} {PresetListDrawer} {EditPresetDrawer} diff --git a/packages/ui/src/elements/PageControls/GroupByPageControls.tsx b/packages/ui/src/elements/PageControls/GroupByPageControls.tsx new file mode 100644 index 000000000..e9ec5c878 --- /dev/null +++ b/packages/ui/src/elements/PageControls/GroupByPageControls.tsx @@ -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 ( + + ) +} diff --git a/packages/ui/src/elements/PageControls/index.scss b/packages/ui/src/elements/PageControls/index.scss new file mode 100644 index 000000000..70be0db96 --- /dev/null +++ b/packages/ui/src/elements/PageControls/index.scss @@ -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); + } + } + } +} diff --git a/packages/ui/src/elements/PageControls/index.tsx b/packages/ui/src/elements/PageControls/index.tsx new file mode 100644 index 000000000..a0ea41745 --- /dev/null +++ b/packages/ui/src/elements/PageControls/index.tsx @@ -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 ( +
+ + {data.totalDocs > 0 && ( + +
+ {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} +
+ + {AfterPageControls} +
+ )} +
+ ) +} + +/* + * 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 ( + + ) +} diff --git a/packages/ui/src/elements/Pagination/ClickableArrow/index.scss b/packages/ui/src/elements/Pagination/ClickableArrow/index.scss index 4cb8c6812..e8c103d79 100644 --- a/packages/ui/src/elements/Pagination/ClickableArrow/index.scss +++ b/packages/ui/src/elements/Pagination/ClickableArrow/index.scss @@ -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); diff --git a/packages/ui/src/elements/Pagination/index.scss b/packages/ui/src/elements/Pagination/index.scss index e7cb22ceb..1bdac9dea 100644 --- a/packages/ui/src/elements/Pagination/index.scss +++ b/packages/ui/src/elements/Pagination/index.scss @@ -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); diff --git a/packages/ui/src/elements/Pagination/index.tsx b/packages/ui/src/elements/Pagination/index.tsx index fb6ef55a2..e2e7ba301 100644 --- a/packages/ui/src/elements/Pagination/index.tsx +++ b/packages/ui/src/elements/Pagination/index.tsx @@ -52,7 +52,7 @@ export const Pagination: React.FC = (props) => { totalPages = null, } = props - if (!hasNextPage && !hasPrevPage) { + if (!hasPrevPage && !hasNextPage) { return null } diff --git a/packages/ui/src/elements/PublishMany/DrawerContent.tsx b/packages/ui/src/elements/PublishMany/DrawerContent.tsx index 7d81e3dbd..6e73afc8b 100644 --- a/packages/ui/src/elements/PublishMany/DrawerContent.tsx +++ b/packages/ui/src/elements/PublishMany/DrawerContent.tsx @@ -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 diff --git a/packages/ui/src/elements/PublishMany/index.tsx b/packages/ui/src/elements/PublishMany/index.tsx index 0df29b185..33dca8d27 100644 --- a/packages/ui/src/elements/PublishMany/index.tsx +++ b/packages/ui/src/elements/PublishMany/index.tsx @@ -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 = (props) => { - const { count, selectAll, selected, toggleAll } = useSelection() + const { count, selectAll, selectedIDs, toggleAll } = useSelection() return ( toggleAll(false)} + ids={selectedIDs} + onSuccess={() => toggleAll()} selectAll={selectAll === SelectAllStatus.AllAvailable} /> ) @@ -31,17 +31,25 @@ export const PublishMany: React.FC = (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 = (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 = (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 = (props) => { ids={ids} onSuccess={onSuccess} selectAll={selectAll} + where={where} />
) diff --git a/packages/ui/src/elements/ReactSelect/types.ts b/packages/ui/src/elements/ReactSelect/types.ts index a2a2e7ca9..72dd352b1 100644 --- a/packages/ui/src/elements/ReactSelect/types.ts +++ b/packages/ui/src/elements/ReactSelect/types.ts @@ -84,6 +84,7 @@ export type ReactSelectAdapterProps = { boolean, GroupBase