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:
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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. |
|
||||
|
||||
199
packages/next/src/views/List/handleGroupBy.ts
Normal file
199
packages/next/src/views/List/handleGroupBy.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ListViewSlots = {
|
||||
BeforeListTable?: React.ReactNode
|
||||
Description?: React.ReactNode
|
||||
listMenuItems?: React.ReactNode[]
|
||||
Table: React.ReactNode
|
||||
Table: React.ReactNode | React.ReactNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,6 +37,7 @@ export type ColumnPreference = {
|
||||
export type CollectionPreferences = {
|
||||
columns?: ColumnPreference[]
|
||||
editViewType?: 'default' | 'live-preview'
|
||||
groupBy?: string
|
||||
limit?: number
|
||||
preset?: DefaultDocumentIDType
|
||||
sort?: string
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'عنصر',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'артикул',
|
||||
|
||||
@@ -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: 'আইটেম',
|
||||
|
||||
@@ -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: 'আইটেম',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'مورد',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'פריט',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'տարր',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'アイテム',
|
||||
|
||||
@@ -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: '항목',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'предмет',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'รายการ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'предмет',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '条目',
|
||||
|
||||
@@ -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: '物品',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
packages/ui/src/elements/GroupByBuilder/index.scss
Normal file
39
packages/ui/src/elements/GroupByBuilder/index.scss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
144
packages/ui/src/elements/GroupByBuilder/index.tsx
Normal file
144
packages/ui/src/elements/GroupByBuilder/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
40
packages/ui/src/elements/PageControls/index.scss
Normal file
40
packages/ui/src/elements/PageControls/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
94
packages/ui/src/elements/PageControls/index.tsx
Normal file
94
packages/ui/src/elements/PageControls/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -52,7 +52,7 @@ export const Pagination: React.FC<PaginationProps> = (props) => {
|
||||
totalPages = null,
|
||||
} = props
|
||||
|
||||
if (!hasNextPage && !hasPrevPage) {
|
||||
if (!hasPrevPage && !hasNextPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}
|
||||
|
||||
27
packages/ui/src/elements/StickyToolbar/index.scss
Normal file
27
packages/ui/src/elements/StickyToolbar/index.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
packages/ui/src/elements/StickyToolbar/index.tsx
Normal file
9
packages/ui/src/elements/StickyToolbar/index.tsx
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 : ''}`
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ export const ListSelection: React.FC<ListSelectionProps> = ({
|
||||
getSelectedItems,
|
||||
moveToFolder,
|
||||
} = useFolder()
|
||||
|
||||
const { clearRouteCache } = useRouteCache()
|
||||
const { config } = useConfig()
|
||||
const { t } = useTranslation()
|
||||
|
||||
17
packages/ui/src/views/List/GroupByHeader/index.scss
Normal file
17
packages/ui/src/views/List/GroupByHeader/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
31
packages/ui/src/views/List/GroupByHeader/index.tsx
Normal file
31
packages/ui/src/views/List/GroupByHeader/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>',
|
||||
)
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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
2
test/group-by/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/media
|
||||
/media-gif
|
||||
16
test/group-by/collections/Categories/index.ts
Normal file
16
test/group-by/collections/Categories/index.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
33
test/group-by/collections/Media/index.ts
Normal file
33
test/group-by/collections/Media/index.ts
Normal 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
Reference in New Issue
Block a user