diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 755f7acde0..e8d852eed5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -309,6 +309,7 @@ jobs: - fields__collections__Text - fields__collections__UI - fields__collections__Upload + - query-presets - form-state - live-preview - localization diff --git a/docs/admin/react-hooks.mdx b/docs/admin/react-hooks.mdx index f5c87207d0..7dcd2a2f80 100644 --- a/docs/admin/react-hooks.mdx +++ b/docs/admin/react-hooks.mdx @@ -474,7 +474,7 @@ Field: '/path/to/CustomArrayManagerField', rows={[ [ { - value: '**\\\`path\\\`**', + value: '**\\`path\\`**', }, { value: 'The path to the array or block field', @@ -482,7 +482,7 @@ Field: '/path/to/CustomArrayManagerField', ], [ { - value: '**\\\`rowIndex\\\`**', + value: '**\\`rowIndex\\`**', }, { value: 'The index of the row to remove', @@ -561,7 +561,7 @@ Field: '/path/to/CustomArrayManagerField', rows={[ [ { - value: '**\\\`path\\\`**', + value: '**\\`path\\`**', }, { value: 'The path to the array or block field', @@ -569,7 +569,7 @@ Field: '/path/to/CustomArrayManagerField', ], [ { - value: '**\\\`rowIndex\\\`**', + value: '**\\`rowIndex\\`**', }, { value: 'The index of the row to replace', @@ -577,7 +577,7 @@ Field: '/path/to/CustomArrayManagerField', ], [ { - value: '**\\\`data\\\`**', + value: '**\\`data\\`**', }, { value: 'The data to replace within the row', @@ -791,17 +791,18 @@ const MyComponent: React.FC = () => { The `useListQuery` hook returns an object with the following properties: -| Property | Description | -| ------------------------- | ------------------------------------------------------------------------ | -| **`data`** | The data that is being displayed in the List View. | -| **`defaultLimit`** | The default limit of items to display in the List View. | -| **`defaultSort`** | The default sort order of items in the List View. | -| **`handlePageChange`** | A method to handle page changes in the List View. | -| **`handlePerPageChange`** | A method to handle per page changes in the List View. | -| **`handleSearchChange`** | A method to handle search changes in the List View. | -| **`handleSortChange`** | A method to handle sort changes in the List View. | -| **`handleWhereChange`** | A method to handle where changes in the List View. | -| **`query`** | The current query that is being used to fetch the data in the List View. | +| Property | Description | +| ------------------------- | -------------------------------------------------------------------------------------- | +| **`data`** | The data that is being displayed in the List View. | +| **`defaultLimit`** | The default limit of items to display in the List View. | +| **`defaultSort`** | The default sort order of items in the List View. | +| **`handlePageChange`** | A method to handle page changes in the List View. | +| **`handlePerPageChange`** | A method to handle per page changes in the List View. | +| **`handleSearchChange`** | A method to handle search changes in the List View. | +| **`handleSortChange`** | A method to handle sort changes in the List View. | +| **`handleWhereChange`** | A method to handle where changes in the List View. | +| **`modified`** | Whether the query has been changed from its [Query Preset](../query-presets/overview). | +| **`query`** | The current query that is being used to fetch the data in the List View. | ## useSelection diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index c239a26890..67402182ba 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -60,29 +60,30 @@ export const Posts: CollectionConfig = { The following options are available: -| Option | Description | -| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). | -| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). | -| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). | -| `custom` | Extension point for adding custom data (e.g. for plugins) | -| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. | -| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. | -| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. | -| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). | -| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). | -| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) | -| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). | -| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | -| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). | -| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. | -| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. | -| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. | -| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. | -| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). | -| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). | -| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. | -| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks | +| Option | Description | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). | +| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). | +| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). | +| `custom` | Extension point for adding custom data (e.g. for plugins) | +| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. | +| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. | +| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. | +| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). | +| `fields` \* | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). | +| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) | +| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). | +| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | +| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). | +| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). | +| `slug` \* | Unique, URL-friendly string that will act as an identifier for this Collection. | +| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. | +| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. | +| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. | +| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). | +| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). | +| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. | +| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks | _\* An asterisk denotes that a property is required._ diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index f2720bb7fd..cf14f14a51 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -84,6 +84,7 @@ The following options are available: | **`csrf`** | A whitelist array of URLs to allow Payload to accept cookies from. [More details](../authentication/cookies#csrf-attacks). | | **`defaultDepth`** | If a user does not specify `depth` while requesting a resource, this depth will be used. [More details](../queries/depth). | | **`defaultMaxTextLength`** | The maximum allowed string length to be permitted application-wide. Helps to prevent malicious public document creation. | +| `queryPresets` | An object that to configure Collection Query Presets. [More details](../query-presets/overview). | | **`maxDepth`** | The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries. Defaults to `10`. [More details](../queries/depth). | | **`indexSortableFields`** | Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. | | **`upload`** | Base Payload upload configuration. [More details](../upload/overview#payload-wide-upload-options). | diff --git a/docs/query-presets/overview.mdx b/docs/query-presets/overview.mdx new file mode 100644 index 0000000000..be94e19293 --- /dev/null +++ b/docs/query-presets/overview.mdx @@ -0,0 +1,171 @@ +--- +title: Query Presets +label: Overview +order: 10 +desc: Query Presets allow you to save and share filters, columns, and sort orders for your collections. +keywords: +--- + +Query Presets allow you to save and share filters, columns, and sort orders for your [Collections](../configuration/collections). This is useful for reusing common or complex filtering patterns and/or sharing them across your team. + +Each Query Preset is saved as a new record in the database under the `payload-query-presets` collection. This allows for an endless number of preset configurations, where the users of your app define the presets that are most useful to them, rather than being hard coded into the Payload Config. + +Within the [Admin Panel](../admin/overview), Query Presets are applied to the List View. When enabled, new controls are displayed for users to manage presets. Once saved, these presets can be loaded up at any time and optionally shared with others. + +To enable Query Presets on a Collection, use the `enableQueryPresets` property in your [Collection Config](../configuration/collections): + +```ts +import type { CollectionConfig } from 'payload' + +export const MyCollection: CollectionConfig = { + // ... + // highlight-start + enableQueryPresets: true, + // highlight-end +} +``` + +## Config Options + +While not required, you may want to customize the behavior of Query Presets to suit your needs, such as add custom labels or access control rules. + +Settings for Query Presets are managed on the `queryPresets` property at the root of your [Payload Config](../configuration/overview): + +```ts +import { buildConfig } from 'payload' + +const config = buildConfig({ + // ... + // highlight-start + queryPresets: { + // ... + }, + // highlight-end +}) +``` + +The following options are available for Query Presets: + +| Option | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `access` | Used to define custom collection-level access control that applies to all presets. [More details](#access-control). | +| `constraints` | Used to define custom document-level access control that apply to individual presets. [More details](#document-access-control). | +| `labels` | Custom labels to use for the Query Presets collection. | + +## Access Control + +Query Presets are subject to the same [Access Control](../access-control/overview) as the rest of Payload. This means you can use the same patterns you are already familiar with to control who can read, update, and delete presets. + +Access Control for Query Presets can be customized in two ways: + +1. [Collection Access Control](#static-access-control): Applies to all presets. These rules are not controllable by the user and are statically defined in the config. +2. [Document Access Control](#dynamic-access-control): Applies to each individual preset. These rules are controllable by the user and are saved to the document. + +### Collection Access Control + +Collection-level access control applies to _all_ presets within the Query Presets collection. Users cannot control these rules, they are written statically in your config. + +To add Collection Access Control, use the `queryPresets.access` property in your [Payload Config](../configuration/overview): + +```ts +import { buildConfig } from 'payload' + +const config = buildConfig({ + // ... + queryPresets: { + // ... + // highlight-start + access: { + read: ({ req: { user } }) => + user ? user?.roles?.some((role) => role === 'admin') : false, + update: ({ req: { user } }) => + user ? user?.roles?.some((role) => role === 'admin') : false, + }, + // highlight-end + }, +}) +``` + +This example restricts all Query Presets to users with the role of `admin`. + + + **Note:** Custom access control will override the defaults on this collection, + including the requirement for a user to be authenticated. Be sure to include + any necessary checks in your custom rules unless you intend on making these + publicly accessible. + + +### Document Access Control + +You can also define access control rules that apply to each specific preset. Users have the ability to define and modify these rules on the fly as they manage presets. These are saved dynamically in the database on each document. + +When a user manages a preset, document-level access control options will be available to them in the Admin Panel for each operation. + +By default, Payload provides a set of sensible defaults for all Query Presets, but you can customize these rules to suit your needs: + +- **Only Me**: Only the user who created the preset can read, update, and delete it. +- **Everyone**: All users can read, update, and delete the preset. +- **Specific Users**: Only select users can read, update, and delete the preset. + +#### Custom Access Control + +You can augment the default access control rules with your own custom rules. This can be useful for creating more complex access control patterns that the defaults don't provide, such as for RBAC. + +Adding custom access control rules requires: + +1. A label to display in the dropdown +2. A set of fields to conditionally render when that option is selected +3. A function that returns the access control rules for that option + +To do this, use the `queryPresets.constraints` property in your [Payload Config](../configuration/payload-config). + +```ts +import { buildConfig } from 'payload' + +const config = buildConfig({ + // ... + queryPresets: { + // ... + // highlight-start + constraints: { + read: { + label: 'Specific Roles', + value: 'specificRoles', + fields: [ + { + name: 'roles', + type: 'select', + hasMany: true, + options: [ + { label: 'Admin', value: 'admin' }, + { label: 'User', value: 'user' }, + ], + }, + ], + access: ({ req: { user } }) => ({ + 'access.read.roles': { + in: [user?.roles], + }, + }), + }, + // highlight-end + }, + }, +}) +``` + +In this example, we've added a new option called `Specific Roles` that allows users to select from a list of roles. When this option is selected, the user will be prompted to select one or more roles from a list of options. The access control rule for this option is that the user operating on the preset must have one of the selected roles. + + + **Note:** Payload places your custom fields into the `access[operation]` field + group, so your rules will need to reflect this. + + +The following options are available for each constraint: + +| Option | Description | +| -------- | ------------------------------------------------------------------------ | +| `label` | The label to display in the dropdown for this constraint. | +| `value` | The value to store in the database when this constraint is selected. | +| `fields` | An array of fields to render when this constraint is selected. | +| `access` | A function that determines the access control rules for this constraint. | diff --git a/packages/next/src/views/Document/handleServerFunction.tsx b/packages/next/src/views/Document/handleServerFunction.tsx index 90632aac28..8838888a64 100644 --- a/packages/next/src/views/Document/handleServerFunction.tsx +++ b/packages/next/src/views/Document/handleServerFunction.tsx @@ -28,6 +28,7 @@ export const renderDocumentHandler = async (args: { initialState?: FormState locale?: Locale overrideEntityVisibility?: boolean + redirectAfterCreate?: boolean redirectAfterDelete: boolean redirectAfterDuplicate: boolean req: PayloadRequest @@ -40,6 +41,7 @@ export const renderDocumentHandler = async (args: { initialData, locale, overrideEntityVisibility, + redirectAfterCreate, redirectAfterDelete, redirectAfterDuplicate, req, @@ -165,6 +167,7 @@ export const renderDocumentHandler = async (args: { segments: ['collections', collectionSlug, docID], }, payload, + redirectAfterCreate, redirectAfterDelete, redirectAfterDuplicate, searchParams: {}, diff --git a/packages/next/src/views/Document/index.tsx b/packages/next/src/views/Document/index.tsx index 74d96af927..bca8ee8bb7 100644 --- a/packages/next/src/views/Document/index.tsx +++ b/packages/next/src/views/Document/index.tsx @@ -44,6 +44,7 @@ export const renderDocument = async ({ initPageResult, overrideEntityVisibility, params, + redirectAfterCreate, redirectAfterDelete, redirectAfterDuplicate, searchParams, @@ -51,6 +52,9 @@ export const renderDocument = async ({ }: { drawerSlug?: string overrideEntityVisibility?: boolean + readonly redirectAfterCreate?: boolean + readonly redirectAfterDelete?: boolean + readonly redirectAfterDuplicate?: boolean } & AdminViewServerProps): Promise<{ data: Data Document: React.ReactNode @@ -308,7 +312,7 @@ export const renderDocument = async ({ id = doc.id isEditing = getIsEditing({ id: doc.id, collectionSlug, globalSlug }) - if (!drawerSlug) { + if (!drawerSlug && redirectAfterCreate !== false) { const redirectURL = formatAdminURL({ adminRoute, path: `/collections/${collectionSlug}/${doc.id}`, @@ -358,6 +362,7 @@ export const renderDocument = async ({ key={locale?.code} lastUpdateTime={lastUpdateTime} mostRecentVersionIsAutosaved={mostRecentVersionIsAutosaved} + redirectAfterCreate={redirectAfterCreate} redirectAfterDelete={redirectAfterDelete} redirectAfterDuplicate={redirectAfterDuplicate} unpublishedVersionCount={unpublishedVersionCount} diff --git a/packages/next/src/views/List/handleServerFunction.tsx b/packages/next/src/views/List/handleServerFunction.tsx index ea792b25ee..f4a3014972 100644 --- a/packages/next/src/views/List/handleServerFunction.tsx +++ b/packages/next/src/views/List/handleServerFunction.tsx @@ -16,6 +16,7 @@ export const renderListHandler = async (args: { disableActions?: boolean disableBulkDelete?: boolean disableBulkEdit?: boolean + disableQueryPresets?: boolean documentDrawerSlug: string drawerSlug?: string enableRowSelections: boolean @@ -30,6 +31,7 @@ export const renderListHandler = async (args: { disableActions, disableBulkDelete, disableBulkEdit, + disableQueryPresets, drawerSlug, enableRowSelections, overrideEntityVisibility, @@ -135,6 +137,7 @@ export const renderListHandler = async (args: { disableActions, disableBulkDelete, disableBulkEdit, + disableQueryPresets, drawerSlug, enableRowSelections, i18n, diff --git a/packages/next/src/views/List/index.tsx b/packages/next/src/views/List/index.tsx index 07a246932b..62e08b1262 100644 --- a/packages/next/src/views/List/index.tsx +++ b/packages/next/src/views/List/index.tsx @@ -1,20 +1,29 @@ +import type { + AdminViewServerProps, + ColumnPreference, + DefaultDocumentIDType, + ListPreferences, + ListQuery, + ListViewClientProps, + ListViewServerPropsOnly, + QueryPreset, + SanitizedCollectionPermission, + Where, +} from 'payload' + import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' import { renderFilters, renderTable, upsertPreferences } from '@payloadcms/ui/rsc' -import { mergeListSearchAndWhere } from '@payloadcms/ui/shared' import { notFound } from 'next/navigation.js' import { - type AdminViewServerProps, - type ColumnPreference, - type ListPreferences, - type ListQuery, - type ListViewClientProps, - type ListViewServerPropsOnly, - type Where, -} from 'payload' -import { formatAdminURL, isNumber, transformColumnsToPreferences } from 'payload/shared' + formatAdminURL, + isNumber, + mergeListSearchAndWhere, + transformColumnsToPreferences, +} from 'payload/shared' import React, { Fragment } from 'react' +import { getDocumentPermissions } from '../Document/getDocumentPermissions.js' import { renderListViewSlots } from './renderListViewSlots.js' import { resolveAllFilterOptions } from './resolveAllFilterOptions.js' @@ -22,10 +31,13 @@ type RenderListViewArgs = { customCellProps?: Record disableBulkDelete?: boolean disableBulkEdit?: boolean + disableQueryPresets?: boolean drawerSlug?: string enableRowSelections: boolean overrideEntityVisibility?: boolean query: ListQuery + redirectAfterDelete?: boolean + redirectAfterDuplicate?: boolean } & AdminViewServerProps export const renderListView = async ( @@ -38,6 +50,7 @@ export const renderListView = async ( customCellProps, disableBulkDelete, disableBulkEdit, + disableQueryPresets, drawerSlug, enableRowSelections, initPageResult, @@ -85,6 +98,7 @@ export const renderListView = async ( value: { columns, limit: isNumber(query?.limit) ? Number(query.limit) : undefined, + preset: (query?.preset as DefaultDocumentIDType) || null, sort: query?.sort as string, }, }) @@ -127,6 +141,32 @@ export const renderListView = async ( } } + let queryPreset: QueryPreset | undefined + let queryPresetPermissions: SanitizedCollectionPermission | undefined + + if (listPreferences?.preset) { + try { + queryPreset = (await payload.findByID({ + id: listPreferences?.preset, + collection: 'payload-query-presets', + depth: 0, + overrideAccess: false, + user, + })) as QueryPreset + + if (queryPreset) { + queryPresetPermissions = await getDocumentPermissions({ + id: queryPreset.id, + collectionConfig: config.collections.find((c) => c.slug === 'payload-query-presets'), + data: queryPreset, + req, + })?.then(({ docPermissions }) => docPermissions) + } + } catch (err) { + req.payload.logger.error(`Error fetching query preset or preset permissions: ${err}`) + } + } + const data = await payload.find({ collection: collectionSlug, depth: 0, @@ -212,6 +252,7 @@ export const renderListView = async ( resolvedFilterOptions?: Map } & ListViewSlots diff --git a/packages/payload/src/bin/generateImportMap/utilities/getFromImportMap.ts b/packages/payload/src/bin/generateImportMap/utilities/getFromImportMap.ts index f8832104d5..1f7fde074b 100644 --- a/packages/payload/src/bin/generateImportMap/utilities/getFromImportMap.ts +++ b/packages/payload/src/bin/generateImportMap/utilities/getFromImportMap.ts @@ -1,5 +1,6 @@ import type { PayloadComponent } from '../../../config/types.js' import type { ImportMap } from '../index.js' + import { parsePayloadComponent } from './parsePayloadComponent.js' export const getFromImportMap = (args: { diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 98b15a040f..c080c2e2b6 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -420,6 +420,11 @@ export type CollectionConfig = { * When true, do not show the "Duplicate" button while editing documents within this collection and prevent `duplicate` from all APIs */ disableDuplicate?: boolean + /** + * Opt-in to enable query presets for this collection. + * @see https://payloadcms.com/docs/query-presets/overview + */ + enableQueryPresets?: boolean /** * Custom rest api endpoints, set false to disable all rest endpoints for this collection. */ diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 16f615abbf..9a6ad9a86e 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -17,6 +17,7 @@ import { } from '../collections/config/client.js' import { createClientBlocks } from '../fields/config/client.js' import { type ClientGlobalConfig, createClientGlobalConfigs } from '../globals/config/client.js' + export type ServerOnlyRootProperties = keyof Pick< SanitizedConfig, | 'bin' @@ -34,6 +35,7 @@ export type ServerOnlyRootProperties = keyof Pick< | 'logger' | 'onInit' | 'plugins' + | 'queryPresets' | 'secret' | 'sharp' | 'typescript' @@ -83,6 +85,7 @@ export const serverOnlyConfigProperties: readonly Partial Promise> = [] const schedulePublishCollections: CollectionSlug[] = [] + + const queryPresetsCollections: CollectionSlug[] = [] + const schedulePublishGlobals: GlobalSlug[] = [] const collectionSlugs = new Set() @@ -192,6 +196,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise 0) { configWithDefaults.collections.push( await sanitizeCollection( config as unknown as Config, - migrationsCollection, + getQueryPresetsConfig(config as unknown as Config), richTextSanitizationPromises, validRelationships, ), @@ -380,9 +397,11 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise[] = [] + for (const sanitizeFunction of richTextSanitizationPromises) { promises.push(sanitizeFunction(config as SanitizedConfig)) } + await Promise.all(promises) return config as SanitizedConfig diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index fd39a85f98..96f3773537 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -49,6 +49,7 @@ import type { RequestContext, TypedUser, } from '../index.js' +import type { QueryPreset, QueryPresetConstraints } from '../query-presets/types.js' import type { PayloadRequest, Where } from '../types/index.js' import type { PayloadLogger } from '../utilities/logger.js' @@ -944,12 +945,12 @@ export type Config = { cookiePrefix?: string /** Either a whitelist array of URLS to allow CORS requests from, or a wildcard string ('*') to accept incoming requests from any domain. */ cors?: '*' | CORSConfig | string[] - /** A whitelist array of URLs to allow Payload cookies to be accepted from as a form of CSRF protection. */ csrf?: string[] /** Extension point to add your custom data. Server only. */ custom?: Record + /** Pass in a database adapter for use on this project. */ db: DatabaseAdapterResult /** Enable to expose more detailed error information. */ @@ -1039,7 +1040,6 @@ export type Config = { * @default false // disable localization */ localization?: false | LocalizationConfig - /** * Logger options, logger options with a destination stream, or an instantiated logger instance. * @@ -1096,6 +1096,7 @@ export type Config = { * @default 10 */ maxDepth?: number + /** A function that is called immediately following startup that receives the Payload instance as its only argument. */ onInit?: (payload: Payload) => Promise | void /** @@ -1104,6 +1105,25 @@ export type Config = { * @see https://payloadcms.com/docs/plugins/overview */ plugins?: Plugin[] + /** + * Allow you to save and share filters, columns, and sort orders for your collections. + * @see https://payloadcms.com/docs/query-presets/overview + */ + queryPresets?: { + access: { + create?: Access + delete?: Access + read?: Access + update?: Access + } + constraints: { + create?: QueryPresetConstraints + delete?: QueryPresetConstraints + read?: QueryPresetConstraints + update?: QueryPresetConstraints + } + labels?: CollectionConfig['labels'] + } /** Control the routing structure that Payload binds itself to. */ routes?: { /** The route for the admin panel. diff --git a/packages/payload/src/exports/components/utilities.ts b/packages/payload/src/exports/components/utilities.ts deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index c404336dc6..0f6b2431bc 100644 --- a/packages/payload/src/exports/shared.ts +++ b/packages/payload/src/exports/shared.ts @@ -45,7 +45,6 @@ export { validOperators, validOperatorSet } from '../types/constants.js' export { formatFilesize } from '../uploads/formatFilesize.js' export { isImage } from '../uploads/isImage.js' - export { deepCopyObject, deepCopyObjectComplex, @@ -61,13 +60,15 @@ export { } from '../utilities/deepMerge.js' export { fieldSchemaToJSON } from '../utilities/fieldSchemaToJSON.js' - export { flattenAllFields } from '../utilities/flattenAllFields.js' + export { default as flattenTopLevelFields } from '../utilities/flattenTopLevelFields.js' export { formatAdminURL } from '../utilities/formatAdminURL.js' - +export { formatLabels, toWords } from '../utilities/formatLabels.js' export { getDataByPath } from '../utilities/getDataByPath.js' + export { getFieldPermissions } from '../utilities/getFieldPermissions.js' + export { getSelectMode } from '../utilities/getSelectMode.js' export { getSiblingData } from '../utilities/getSiblingData.js' @@ -86,6 +87,11 @@ export { isReactServerComponentOrFunction, } from '../utilities/isReactComponent.js' +export { + hoistQueryParamsToAnd, + mergeListSearchAndWhere, +} from '../utilities/mergeListSearchAndWhere.js' + export { reduceFieldsToValues } from '../utilities/reduceFieldsToValues.js' export { setsAreEqual } from '../utilities/setsAreEqual.js' @@ -97,8 +103,11 @@ export { transformColumnsToSearchParams, } from '../utilities/transformColumnPreferences.js' +export { transformWhereQuery } from '../utilities/transformWhereQuery.js' + export { unflatten } from '../utilities/unflatten.js' export { validateMimeType } from '../utilities/validateMimeType.js' +export { validateWhereQuery } from '../utilities/validateWhereQuery.js' export { wait } from '../utilities/wait.js' export { default as wordBoundariesRegex } from '../utilities/wordBoundariesRegex.js' export { versionDefaults } from '../versions/defaults.js' diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index ea94b55c18..61c24caa9e 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1391,6 +1391,7 @@ export type { PreferenceUpdateRequest, TabsPreferences, } from './preferences/types.js' +export type { QueryPreset } from './query-presets/types.js' export { jobAfterRead } from './queues/config/index.js' export type { JobsConfig, RunJobAccess, RunJobAccessArgs } from './queues/config/types/index.js' diff --git a/packages/payload/src/preferences/types.ts b/packages/payload/src/preferences/types.ts index fff45efe63..49f110eb02 100644 --- a/packages/payload/src/preferences/types.ts +++ b/packages/payload/src/preferences/types.ts @@ -1,3 +1,4 @@ +import type { DefaultDocumentIDType } from '../index.js' import type { PayloadRequest } from '../types/index.js' export type PreferenceRequest = { @@ -36,5 +37,6 @@ export type ColumnPreference = { export type ListPreferences = { columns?: ColumnPreference[] limit?: number + preset?: DefaultDocumentIDType sort?: string } diff --git a/packages/payload/src/query-presets/access.ts b/packages/payload/src/query-presets/access.ts new file mode 100644 index 0000000000..4d62a088ca --- /dev/null +++ b/packages/payload/src/query-presets/access.ts @@ -0,0 +1,94 @@ +import type { Access, Config } from '../config/types.js' +import type { Operation } from '../types/index.js' + +import defaultAccess from '../auth/defaultAccess.js' + +const operations: Operation[] = ['delete', 'read', 'update', 'create'] as const + +const defaultCollectionAccess = { + create: defaultAccess, + delete: defaultAccess, + read: defaultAccess, + unlock: defaultAccess, + update: defaultAccess, +} + +export const getAccess = (config: Config): Record => + operations.reduce( + (acc, operation) => { + acc[operation] = async (args) => { + const { req } = args + + const collectionAccess = config?.queryPresets?.access?.[operation] + ? await config.queryPresets.access[operation](args) + : defaultCollectionAccess?.[operation] + ? defaultCollectionAccess[operation](args) + : true + + // If collection-level access control is `false`, no need to continue to document-level access + if (collectionAccess === false) { + return false + } + + // The `create` operation does not affect the document-level access control + if (operation === 'create') { + return collectionAccess + } + + return { + and: [ + { + or: [ + // Default access control ensures a user exists, but custom access control may not + ...(req?.user + ? [ + { + and: [ + { + [`access.${operation}.users`]: { + in: [req.user.id], + }, + }, + { + [`access.${operation}.constraint`]: { + in: ['onlyMe', 'specificUsers'], + }, + }, + ], + }, + ] + : []), + { + [`access.${operation}.constraint`]: { + equals: 'everyone', + }, + }, + ...(await Promise.all( + (config?.queryPresets?.constraints?.[operation] || []).map(async (constraint) => { + const constraintAccess = constraint.access + ? await constraint.access(args) + : undefined + + return { + and: [ + ...(typeof constraintAccess === 'object' ? [constraintAccess] : []), + { + [`access.${operation}.constraint`]: { + equals: constraint.value, + }, + }, + ], + } + }), + )), + ], + }, + ...(typeof collectionAccess === 'object' ? [collectionAccess] : []), + ], + } + } + + return acc + }, + {} as Record, + ) diff --git a/packages/payload/src/query-presets/config.ts b/packages/payload/src/query-presets/config.ts new file mode 100644 index 0000000000..ba2d3ee6ff --- /dev/null +++ b/packages/payload/src/query-presets/config.ts @@ -0,0 +1,164 @@ +import type { CollectionConfig } from '../collections/config/types.js' +import type { Config } from '../config/types.js' +import type { Option } from '../fields/config/types.js' + +import { transformWhereQuery } from '../utilities/transformWhereQuery.js' +import { validateWhereQuery } from '../utilities/validateWhereQuery.js' +import { getAccess } from './access.js' +import { getConstraints } from './constraints.js' +import { operations, type QueryPreset } from './types.js' + +export const queryPresetsCollectionSlug = 'payload-query-presets' + +export const getQueryPresetsConfig = (config: Config): CollectionConfig => ({ + slug: queryPresetsCollectionSlug, + access: getAccess(config), + admin: { + defaultColumns: ['title', 'isShared', 'access', 'where', 'columns'], + hidden: true, + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'isShared', + type: 'checkbox', + defaultValue: false, + validate: (isShared, { data }) => { + const typedData = data as QueryPreset + + // ensure the `isShared` is only true if all constraints are 'onlyMe' + if (typedData?.access) { + const someOperationsAreShared = Object.values(typedData.access).some( + (operation) => operation.constraint !== 'onlyMe', + ) + + if (!isShared && someOperationsAreShared) { + return 'If any constraint is not "onlyMe", the preset must be shared' + } + } + + return true + }, + }, + getConstraints(config), + { + name: 'where', + type: 'json', + admin: { + components: { + Cell: '@payloadcms/ui#QueryPresetsWhereCell', + Field: '@payloadcms/ui#QueryPresetsWhereField', + }, + }, + hooks: { + beforeValidate: [ + ({ data }) => { + // transform the "where" query here so that the client-side doesn't have to + if (data?.where) { + if (validateWhereQuery(data.where)) { + return data.where + } else { + return transformWhereQuery(data.where) + } + } + + return data?.where + }, + ], + }, + label: 'Filters', + }, + { + name: 'columns', + type: 'json', + admin: { + components: { + Cell: '@payloadcms/ui#QueryPresetsColumnsCell', + Field: '@payloadcms/ui#QueryPresetsColumnField', + }, + }, + validate: (value) => { + if (value) { + try { + JSON.parse(JSON.stringify(value)) + } catch { + return 'Invalid JSON' + } + } + + return true + }, + }, + { + name: 'relatedCollection', + type: 'select', + admin: { + hidden: true, + }, + options: config.collections + ? config.collections.reduce((acc, collection) => { + if (collection.enableQueryPresets) { + acc.push({ + label: collection.labels?.plural || collection.slug, + value: collection.slug, + }) + } + return acc + }, [] as Option[]) + : [], + required: true, + }, + ], + hooks: { + beforeValidate: [ + ({ data, operation, req }) => { + // TODO: type this + const typedData = data as any + + if (operation === 'create' || operation === 'update') { + // Ensure all operations have a constraint + operations.forEach((operation) => { + if (!typedData.access) { + typedData.access = {} + } + + if (!typedData.access?.[operation]) { + typedData[operation] = {} + } + + // Ensure all operations have a constraint + if (!typedData.access[operation]?.constraint) { + typedData.access[operation] = { + ...typedData.access[operation], + constraint: 'onlyMe', + } + } + }) + + // If at least one constraint is not `onlyMe` then `isShared` must be true + if (typedData?.access) { + const someOperationsAreShared = Object.values(typedData.access).some( + // TODO: remove the `any` here + (operation: any) => operation.constraint !== 'onlyMe', + ) + + typedData.isShared = someOperationsAreShared + } + } + + return typedData + }, + ], + }, + labels: { + plural: 'Presets', + singular: 'Preset', + ...(config.queryPresets?.labels || {}), + }, + lockDocuments: false, +}) diff --git a/packages/payload/src/query-presets/constraints.ts b/packages/payload/src/query-presets/constraints.ts new file mode 100644 index 0000000000..2453276b54 --- /dev/null +++ b/packages/payload/src/query-presets/constraints.ts @@ -0,0 +1,104 @@ +import { getTranslation } from '@payloadcms/translations' + +import type { Config } from '../config/types.js' +import type { Field } from '../fields/config/types.js' + +import { fieldAffectsData } from '../fields/config/types.js' +import { toWords } from '../utilities/formatLabels.js' +import { operations, type QueryPresetConstraint } from './types.js' + +export const getConstraints = (config: Config): Field => ({ + name: 'access', + type: 'group', + admin: { + components: { + Cell: '@payloadcms/ui#QueryPresetsAccessCell', + }, + condition: (data) => Boolean(data?.isShared), + }, + fields: operations.map((operation) => ({ + type: 'collapsible', + fields: [ + { + name: operation, + type: 'group', + admin: { + hideGutter: true, + }, + fields: [ + { + name: 'constraint', + type: 'select', + defaultValue: 'onlyMe', + label: ({ i18n }) => + `Specify who can ${operation} this ${getTranslation(config.queryPresets?.labels?.singular || 'Preset', i18n)}`, + options: [ + { + label: 'Everyone', + value: 'everyone', + }, + { + label: 'Only Me', + value: 'onlyMe', + }, + { + label: 'Specific Users', + value: 'specificUsers', + }, + ...(config?.queryPresets?.constraints?.[operation]?.map( + (option: QueryPresetConstraint) => ({ + label: option.label, + value: option.value, + }), + ) || []), + ], + }, + { + name: 'users', + type: 'relationship', + admin: { + condition: (data) => + Boolean(data?.access?.[operation]?.constraint === 'specificUsers'), + }, + hasMany: true, + hooks: { + beforeChange: [ + ({ data, req }) => { + if (data?.access?.[operation]?.constraint === 'onlyMe') { + if (req.user) { + return [req.user.id] + } + } + + return data?.access?.[operation]?.users + }, + ], + }, + relationTo: 'users', + }, + ...(config?.queryPresets?.constraints?.[operation]?.reduce( + (acc: Field[], option: QueryPresetConstraint) => { + option.fields.forEach((field, index) => { + acc.push({ ...field }) + + if (fieldAffectsData(field)) { + acc[index].admin = { + ...(acc[index]?.admin || {}), + condition: (data) => + Boolean(data?.access?.[operation]?.constraint === option.value), + } + } + }) + + return acc + }, + [] as Field[], + ) || []), + ], + label: false, + }, + ], + label: () => toWords(operation), + })), + label: 'Sharing settings', +}) diff --git a/packages/payload/src/query-presets/types.ts b/packages/payload/src/query-presets/types.ts new file mode 100644 index 0000000000..a2f35de730 --- /dev/null +++ b/packages/payload/src/query-presets/types.ts @@ -0,0 +1,33 @@ +import type { Field } from '../fields/config/types.js' +import type { Access, CollectionSlug } from '../index.js' +import type { ListPreferences } from '../preferences/types.js' +import type { Where } from '../types/index.js' + +// Note: order matters here as it will change the rendered order in the UI +export const operations = ['read', 'update', 'delete'] as const + +type Operation = (typeof operations)[number] + +export type QueryPreset = { + access: { + [operation in Operation]: { + constraint: 'everyone' | 'onlyMe' | 'specificUsers' + users?: string[] + } + } + columns: ListPreferences['columns'] + id: number | string + isShared: boolean + relatedCollection: CollectionSlug + title: string + where: Where +} + +export type QueryPresetConstraint = { + access: Access + fields: Field[] + label: string + value: string +} + +export type QueryPresetConstraints = QueryPresetConstraint[] diff --git a/packages/ui/src/utilities/mergeListSearchAndWhere.ts b/packages/payload/src/utilities/mergeListSearchAndWhere.ts similarity index 83% rename from packages/ui/src/utilities/mergeListSearchAndWhere.ts rename to packages/payload/src/utilities/mergeListSearchAndWhere.ts index 7e9ad5ba0e..0824be02e9 100644 --- a/packages/ui/src/utilities/mergeListSearchAndWhere.ts +++ b/packages/payload/src/utilities/mergeListSearchAndWhere.ts @@ -1,4 +1,6 @@ -import type { ClientCollectionConfig, SanitizedCollectionConfig, Where } from 'payload' +import type { ClientCollectionConfig } from '../collections/config/client.js' +import type { SanitizedCollectionConfig } from '../collections/config/types.js' +import type { Where } from '../types/index.js' const isEmptyObject = (obj: object) => Object.keys(obj).length === 0 @@ -11,7 +13,7 @@ export const hoistQueryParamsToAnd = (currentWhere: Where, incomingWhere: Where) return incomingWhere } - if ('and' in currentWhere) { + if ('and' in currentWhere && currentWhere.and) { currentWhere.and.push(incomingWhere) } else if ('or' in currentWhere) { currentWhere = { diff --git a/packages/ui/src/elements/WhereBuilder/transformWhereQuery.ts b/packages/payload/src/utilities/transformWhereQuery.ts similarity index 64% rename from packages/ui/src/elements/WhereBuilder/transformWhereQuery.ts rename to packages/payload/src/utilities/transformWhereQuery.ts index f61ca5f27c..8bfa199bb0 100644 --- a/packages/ui/src/elements/WhereBuilder/transformWhereQuery.ts +++ b/packages/payload/src/utilities/transformWhereQuery.ts @@ -1,15 +1,18 @@ -'use client' -import type { Where } from 'payload' +import type { Where } from '../types/index.js' /** - * Something like [or][0][and][0][text][equals]=example%20post will work and pass through the validateWhereQuery check. - * However, something like [text][equals]=example%20post will not work and will fail the validateWhereQuery check, - * even though it is a valid Where query. This needs to be transformed here. + * Transforms a basic "where" query into a format in which the "where builder" can understand. + * Even though basic queries are valid, we need to hoist them into the "and" / "or" format. + * Use this function alongside `validateWhereQuery` to check that for valid queries before transforming. + * @example + * Inaccurate: [text][equals]=example%20post + * Accurate: [or][0][and][0][text][equals]=example%20post */ -export const transformWhereQuery = (whereQuery): Where => { +export const transformWhereQuery = (whereQuery: Where): Where => { if (!whereQuery) { return {} } + // Check if 'whereQuery' has 'or' field but no 'and'. This is the case for "correct" queries if (whereQuery.or && !whereQuery.and) { return { diff --git a/packages/ui/src/elements/WhereBuilder/validateWhereQuery.ts b/packages/payload/src/utilities/validateWhereQuery.ts similarity index 63% rename from packages/ui/src/elements/WhereBuilder/validateWhereQuery.ts rename to packages/payload/src/utilities/validateWhereQuery.ts index 725a6849a0..bf9b4d802c 100644 --- a/packages/ui/src/elements/WhereBuilder/validateWhereQuery.ts +++ b/packages/payload/src/utilities/validateWhereQuery.ts @@ -1,10 +1,18 @@ -'use client' -import type { Operator, Where } from 'payload' +import type { Operator, Where } from '../types/index.js' -import { validOperatorSet } from 'payload/shared' +import { validOperatorSet } from '../types/constants.js' -const validateWhereQuery = (whereQuery): whereQuery is Where => { +/** + * Validates that a "where" query is in a format in which the "where builder" can understand. + * Even though basic queries are valid, we need to hoist them into the "and" / "or" format. + * Use this function alongside `transformWhereQuery` to perform a transformation if the query is not valid. + * @example + * Inaccurate: [text][equals]=example%20post + * Accurate: [or][0][and][0][text][equals]=example%20post + */ +export const validateWhereQuery = (whereQuery: Where): whereQuery is Where => { if ( + whereQuery?.or && whereQuery?.or?.length > 0 && whereQuery?.or?.[0]?.and && whereQuery?.or?.[0]?.and?.length > 0 @@ -44,5 +52,3 @@ const validateWhereQuery = (whereQuery): whereQuery is Where => { return false } - -export default validateWhereQuery diff --git a/packages/translations/src/clientKeys.ts b/packages/translations/src/clientKeys.ts index 60c87ee14f..d92cb05690 100644 --- a/packages/translations/src/clientKeys.ts +++ b/packages/translations/src/clientKeys.ts @@ -252,6 +252,7 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:selectAll', 'general:selectAllRows', 'general:selectedCount', + 'general:selectLabel', 'general:selectValue', 'general:showAllLabel', 'general:sorryNotFound', @@ -280,7 +281,9 @@ export const clientTranslationKeys = createClientTranslationKeys([ 'general:unsavedChangesDuplicate', 'general:untitled', 'general:updatedAt', + 'general:updatedLabelSuccessfully', 'general:updatedCountSuccessfully', + 'general:updateForEveryone', 'general:updatedSuccessfully', 'general:updating', 'general:value', diff --git a/packages/translations/src/languages/ar.ts b/packages/translations/src/languages/ar.ts index 643f6dd8d5..af7c4f25f8 100644 --- a/packages/translations/src/languages/ar.ts +++ b/packages/translations/src/languages/ar.ts @@ -311,6 +311,7 @@ export const arTranslations: DefaultTranslationsObject = { selectAll: 'تحديد كل {{count}} {{label}}', selectAllRows: 'حدد جميع الصفوف', selectedCount: 'تم تحديد {{count}} {{label}}', + selectLabel: 'حدد {{label}}', selectValue: 'اختيار قيمة', showAllLabel: 'عرض كل {{label}}', sorryNotFound: 'عذرًا - لا يوجد شيء يتوافق مع طلبك.', @@ -338,7 +339,9 @@ export const arTranslations: DefaultTranslationsObject = { upcomingEvents: 'الأحداث القادمة', updatedAt: 'تم التحديث في', updatedCountSuccessfully: 'تم تحديث {{count}} {{label}} بنجاح.', + updatedLabelSuccessfully: 'تم تحديث {{label}} بنجاح.', updatedSuccessfully: 'تم التحديث بنجاح.', + updateForEveryone: 'تحديث للجميع', updating: 'جار التحديث', uploading: 'جار الرفع', uploadingBulk: 'جاري التحميل {{current}} من {{total}}', diff --git a/packages/translations/src/languages/az.ts b/packages/translations/src/languages/az.ts index ace7b7b191..717acbe639 100644 --- a/packages/translations/src/languages/az.ts +++ b/packages/translations/src/languages/az.ts @@ -314,6 +314,7 @@ export const azTranslations: DefaultTranslationsObject = { selectAll: 'Bütün {{count}} {{label}} seç', selectAllRows: 'Bütün sıraları seçin', selectedCount: '{{count}} {{label}} seçildi', + selectLabel: '{{label}} seçin', selectValue: 'Dəyər seçin', showAllLabel: 'Bütün {{label}}-ı göstər', sorryNotFound: 'Üzr istəyirik - sizin tələbinizə uyğun heç nə yoxdur.', @@ -343,7 +344,9 @@ export const azTranslations: DefaultTranslationsObject = { upcomingEvents: 'Gələcək Tədbirlər', updatedAt: 'Yeniləndiyi tarix', updatedCountSuccessfully: '{{count}} {{label}} uğurla yeniləndi.', + updatedLabelSuccessfully: '{{label}} uğurla yeniləndi.', updatedSuccessfully: 'Uğurla yeniləndi.', + updateForEveryone: 'Hər kəs üçün yeniləmə', updating: 'Yenilənir', uploading: 'Yüklənir', uploadingBulk: '{{total}}-dan {{current}}-un yüklənməsi', diff --git a/packages/translations/src/languages/bg.ts b/packages/translations/src/languages/bg.ts index e4ef1cf723..5b3cc213c0 100644 --- a/packages/translations/src/languages/bg.ts +++ b/packages/translations/src/languages/bg.ts @@ -314,6 +314,7 @@ export const bgTranslations: DefaultTranslationsObject = { selectAll: 'Избери всички {{count}} {{label}}', selectAllRows: 'Избери всички редове', selectedCount: '{{count}} {{label}} избрани', + selectLabel: 'Изберете {{label}}', selectValue: 'Избери стойност', showAllLabel: 'Покажи всички {{label}}', sorryNotFound: 'Съжаляваме-няма нищо, което да отговаря на търсенето ти.', @@ -341,7 +342,9 @@ export const bgTranslations: DefaultTranslationsObject = { upcomingEvents: 'Предстоящи събития', updatedAt: 'Обновен на', updatedCountSuccessfully: 'Обновени {{count}} {{label}} успешно.', + updatedLabelSuccessfully: 'Успешно обновихме {{label}}.', updatedSuccessfully: 'Обновен успешно.', + updateForEveryone: 'Актуализация за всички', updating: 'Обновява се', uploading: 'Качва се', uploadingBulk: 'Качване на {{current}} от {{total}}', diff --git a/packages/translations/src/languages/ca.ts b/packages/translations/src/languages/ca.ts index 140d85406b..c6235daf44 100644 --- a/packages/translations/src/languages/ca.ts +++ b/packages/translations/src/languages/ca.ts @@ -315,6 +315,7 @@ export const caTranslations: DefaultTranslationsObject = { selectAll: 'Selecciona totes les {{count}} {{label}}', selectAllRows: 'Selecciona totes les files', selectedCount: '{{count}} {{label}} seleccionats', + selectLabel: 'Selecciona {{label}}', selectValue: 'Selecciona un valor', showAllLabel: 'Mostra totes {{label}}', sorryNotFound: "Ho sento, no s'ha trobat la pàgina que busques.", @@ -342,7 +343,9 @@ export const caTranslations: DefaultTranslationsObject = { upcomingEvents: 'Esdeveniments programats', updatedAt: 'Actualitzat el', updatedCountSuccessfully: 'Actualitzat {{count}} {{label}} correctament.', + updatedLabelSuccessfully: 'Actualitzat {{label}} amb èxit.', updatedSuccessfully: 'Actualitzat amb exit.', + updateForEveryone: 'Actualització per a tothom', updating: 'Actualitzant', uploading: 'Pujant', uploadingBulk: 'Pujant {{current}} de {{total}}', diff --git a/packages/translations/src/languages/cs.ts b/packages/translations/src/languages/cs.ts index 7b6a62e01f..56b59453fa 100644 --- a/packages/translations/src/languages/cs.ts +++ b/packages/translations/src/languages/cs.ts @@ -312,6 +312,7 @@ export const csTranslations: DefaultTranslationsObject = { selectAll: 'Vybrat vše {{count}} {{label}}', selectAllRows: 'Vyberte všechny řádky', selectedCount: 'Vybráno {{count}} {{label}}', + selectLabel: 'Vyberte {{label}}', selectValue: 'Vyberte hodnotu', showAllLabel: 'Zobrazit všechny {{label}}', sorryNotFound: 'Je nám líto, ale neexistuje nic, co by odpovídalo vašemu požadavku.', @@ -339,7 +340,9 @@ export const csTranslations: DefaultTranslationsObject = { upcomingEvents: 'Nadcházející události', updatedAt: 'Aktualizováno v', updatedCountSuccessfully: 'Úspěšně aktualizováno {{count}} {{label}}.', + updatedLabelSuccessfully: 'Úspěšně aktualizovaný {{label}}.', updatedSuccessfully: 'Úspěšně aktualizováno.', + updateForEveryone: 'Aktualizace pro všechny', updating: 'Aktualizace', uploading: 'Nahrávání', uploadingBulk: 'Nahrávání {{current}} z {{total}}', diff --git a/packages/translations/src/languages/da.ts b/packages/translations/src/languages/da.ts index d66a3bf5d7..9aa2081509 100644 --- a/packages/translations/src/languages/da.ts +++ b/packages/translations/src/languages/da.ts @@ -313,6 +313,7 @@ export const daTranslations: DefaultTranslationsObject = { selectAll: 'Vælg alle {{count}} {{label}}', selectAllRows: 'Vælg alle rækker', selectedCount: '{{count}} {{label}} valgt', + selectLabel: 'Vælg {{label}}', selectValue: 'Vælg en værdi', showAllLabel: 'Vis alle {{label}}', sorryNotFound: 'Beklager—der er intet, der svarer til din handling.', @@ -340,7 +341,9 @@ export const daTranslations: DefaultTranslationsObject = { upcomingEvents: 'Kommende Begivenheder', updatedAt: 'Opdateret ved', updatedCountSuccessfully: 'Opdateret {{count}} {{label}} successfully.', + updatedLabelSuccessfully: 'Opdaterede {{label}} med succes.', updatedSuccessfully: 'Opdateret.', + updateForEveryone: 'Opdatering for alle', updating: 'Opdaterer', uploading: 'Uploader', uploadingBulk: 'Uploader {{current}} af {{total}}', diff --git a/packages/translations/src/languages/de.ts b/packages/translations/src/languages/de.ts index c8476df60e..f926cf8fed 100644 --- a/packages/translations/src/languages/de.ts +++ b/packages/translations/src/languages/de.ts @@ -318,6 +318,7 @@ export const deTranslations: DefaultTranslationsObject = { selectAll: 'Alle auswählen {{count}} {{label}}', selectAllRows: 'Wählen Sie alle Zeilen aus', selectedCount: '{{count}} {{label}} ausgewählt', + selectLabel: 'Wählen Sie {{label}}', selectValue: 'Wert auswählen', showAllLabel: 'Zeige alle {{label}}', sorryNotFound: 'Entschuldige, es entspricht nichts deiner Anfrage', @@ -347,7 +348,9 @@ export const deTranslations: DefaultTranslationsObject = { upcomingEvents: 'Bevorstehende Veranstaltungen', updatedAt: 'Aktualisiert am', updatedCountSuccessfully: '{{count}} {{label}} erfolgreich aktualisiert.', + updatedLabelSuccessfully: '{{label}} erfolgreich aktualisiert.', updatedSuccessfully: 'Erfolgreich aktualisiert.', + updateForEveryone: 'Aktualisierung für alle', updating: 'Aktualisierung', uploading: 'Hochladen', uploadingBulk: 'Hochladen von {{current}} von {{total}}', diff --git a/packages/translations/src/languages/en.ts b/packages/translations/src/languages/en.ts index e55801fad0..5aba5fa6ad 100644 --- a/packages/translations/src/languages/en.ts +++ b/packages/translations/src/languages/en.ts @@ -315,6 +315,7 @@ export const enTranslations = { selectAll: 'Select all {{count}} {{label}}', selectAllRows: 'Select all rows', selectedCount: '{{count}} {{label}} selected', + selectLabel: 'Select {{label}}', selectValue: 'Select a value', showAllLabel: 'Show all {{label}}', sorryNotFound: 'Sorry—there is nothing to correspond with your request.', @@ -342,7 +343,9 @@ export const enTranslations = { upcomingEvents: 'Upcoming Events', updatedAt: 'Updated At', updatedCountSuccessfully: 'Updated {{count}} {{label}} successfully.', + updatedLabelSuccessfully: 'Updated {{label}} successfully.', updatedSuccessfully: 'Updated successfully.', + updateForEveryone: 'Update for everyone', updating: 'Updating', uploading: 'Uploading', uploadingBulk: 'Uploading {{current}} of {{total}}', diff --git a/packages/translations/src/languages/es.ts b/packages/translations/src/languages/es.ts index bede932313..49248629bc 100644 --- a/packages/translations/src/languages/es.ts +++ b/packages/translations/src/languages/es.ts @@ -319,6 +319,7 @@ export const esTranslations: DefaultTranslationsObject = { selectAll: 'Seleccionar todo {{count}} {{label}}', selectAllRows: 'Selecciona todas las filas', selectedCount: '{{count}} {{label}} seleccionado', + selectLabel: 'Seleccione {{label}}', selectValue: 'Selecciona un valor', showAllLabel: 'Muestra todas {{label}}', sorryNotFound: 'Lo sentimos. No hay nada que corresponda con tu solicitud.', @@ -346,7 +347,9 @@ export const esTranslations: DefaultTranslationsObject = { upcomingEvents: 'Próximos Eventos', updatedAt: 'Fecha de modificado', updatedCountSuccessfully: '{{count}} {{label}} actualizado con éxito.', + updatedLabelSuccessfully: 'Actualizado {{label}} con éxito.', updatedSuccessfully: 'Actualizado con éxito.', + updateForEveryone: 'Actualización para todos', updating: 'Actualizando', uploading: 'Subiendo', uploadingBulk: 'Subiendo {{current}} de {{total}}', diff --git a/packages/translations/src/languages/et.ts b/packages/translations/src/languages/et.ts index fd02081934..06ba68fcf9 100644 --- a/packages/translations/src/languages/et.ts +++ b/packages/translations/src/languages/et.ts @@ -311,6 +311,7 @@ export const etTranslations: DefaultTranslationsObject = { selectAll: 'Vali kõik {{count}} {{label}}', selectAllRows: 'Vali kõik read', selectedCount: '{{count}} {{label}} valitud', + selectLabel: 'Valige {{label}}', selectValue: 'Vali väärtus', showAllLabel: 'Näita kõiki {{label}}', sorryNotFound: 'Vabandust - teie päringule vastavat sisu ei leitud.', @@ -338,7 +339,9 @@ export const etTranslations: DefaultTranslationsObject = { upcomingEvents: 'Eelseisvad sündmused', updatedAt: 'Uuendatud', updatedCountSuccessfully: 'Uuendatud {{count}} {{label}} edukalt.', + updatedLabelSuccessfully: 'Uuendas {{label}} edukalt.', updatedSuccessfully: 'Edukalt uuendatud.', + updateForEveryone: 'Uuendus kõigile', updating: 'Uuendamine', uploading: 'Üleslaadimine', uploadingBulk: 'Üleslaadimine {{current}} / {{total}}', diff --git a/packages/translations/src/languages/fa.ts b/packages/translations/src/languages/fa.ts index e6a89079e3..8a17f5be61 100644 --- a/packages/translations/src/languages/fa.ts +++ b/packages/translations/src/languages/fa.ts @@ -312,6 +312,7 @@ export const faTranslations: DefaultTranslationsObject = { selectAll: 'انتخاب همه {{count}} {{label}}', selectAllRows: 'انتخاب تمام سطرها', selectedCount: '{{count}} {{label}} انتخاب شد', + selectLabel: '{{label}} را انتخاب کنید', selectValue: 'یک مقدار را انتخاب کنید', showAllLabel: 'نمایش همه {{label}}', sorryNotFound: 'متأسفانه چیزی برای مطابقت با درخواست شما وجود ندارد.', @@ -339,7 +340,9 @@ export const faTranslations: DefaultTranslationsObject = { upcomingEvents: 'رویدادهای آینده', updatedAt: 'بروز شده در', updatedCountSuccessfully: 'تعداد {{count}} با عنوان {{label}} با موفقیت بروزرسانی شدند.', + updatedLabelSuccessfully: 'به روزرسانی {{label}} با موفقیت انجام شد.', updatedSuccessfully: 'با موفقیت به‌روز شد.', + updateForEveryone: 'بروزرسانی برای همه', updating: 'در حال به‌روزرسانی', uploading: 'در حال بارگذاری', uploadingBulk: 'بارگذاری {{current}} از {{total}}', diff --git a/packages/translations/src/languages/fr.ts b/packages/translations/src/languages/fr.ts index 58eb27c248..6b49660418 100644 --- a/packages/translations/src/languages/fr.ts +++ b/packages/translations/src/languages/fr.ts @@ -322,6 +322,7 @@ export const frTranslations: DefaultTranslationsObject = { selectAll: 'Tout sélectionner {{count}} {{label}}', selectAllRows: 'Sélectionnez toutes les lignes', selectedCount: '{{count}} {{label}} sélectionné', + selectLabel: 'Sélectionnez {{label}}', selectValue: 'Sélectionnez une valeur', showAllLabel: 'Afficher tous les {{label}}', sorryNotFound: 'Désolé, rien ne correspond à votre demande.', @@ -351,7 +352,9 @@ export const frTranslations: DefaultTranslationsObject = { upcomingEvents: 'Événements à venir', updatedAt: 'Modifié le', updatedCountSuccessfully: '{{count}} {{label}} mis à jour avec succès.', + updatedLabelSuccessfully: '{{label}} mis à jour avec succès.', updatedSuccessfully: 'Mis à jour avec succès.', + updateForEveryone: 'Mise à jour pour tout le monde', updating: 'Mise à jour', uploading: 'Téléchargement', uploadingBulk: 'Téléchargement de {{current}} sur {{total}}', diff --git a/packages/translations/src/languages/he.ts b/packages/translations/src/languages/he.ts index 0f20ccdc47..c2bc56a717 100644 --- a/packages/translations/src/languages/he.ts +++ b/packages/translations/src/languages/he.ts @@ -307,6 +307,7 @@ export const heTranslations: DefaultTranslationsObject = { selectAll: 'בחר את כל {{count}} ה{{label}}', selectAllRows: 'בחר את כל השורות', selectedCount: '{{count}} {{label}} נבחרו', + selectLabel: '{{label}} בחר', selectValue: 'בחר ערך', showAllLabel: 'הצג את כל ה{{label}}', sorryNotFound: 'מצטערים - אין תוצאות התואמות את הבקשה.', @@ -334,7 +335,9 @@ export const heTranslations: DefaultTranslationsObject = { upcomingEvents: 'אירועים קרובים', updatedAt: 'עודכן בתאריך', updatedCountSuccessfully: 'עודכן {{count}} {{label}} בהצלחה.', + updatedLabelSuccessfully: 'עודכן {{label}} בהצלחה.', updatedSuccessfully: 'עודכן בהצלחה.', + updateForEveryone: 'עדכון לכולם', updating: 'מעדכן', uploading: 'מעלה', uploadingBulk: 'מעלה {{current}} מתוך {{total}}', diff --git a/packages/translations/src/languages/hr.ts b/packages/translations/src/languages/hr.ts index 271fef8446..0ee1aaf478 100644 --- a/packages/translations/src/languages/hr.ts +++ b/packages/translations/src/languages/hr.ts @@ -314,6 +314,7 @@ export const hrTranslations: DefaultTranslationsObject = { selectAll: 'Odaberite sve {{count}} {{label}}', selectAllRows: 'Odaberite sve redove', selectedCount: '{{count}} {{label}} odabrano', + selectLabel: 'Odaberite {{label}}', selectValue: 'Odaberi vrijednost', showAllLabel: 'Prikaži sve {{label}}', sorryNotFound: 'Nažalost, ne postoji ništa što odgovara vašem zahtjevu.', @@ -341,7 +342,9 @@ export const hrTranslations: DefaultTranslationsObject = { upcomingEvents: 'Nadolazeći događaji', updatedAt: 'Ažurirano u', updatedCountSuccessfully: 'Uspješno ažurirano {{count}} {{label}}.', + updatedLabelSuccessfully: 'Uspješno ažurirano {{label}}.', updatedSuccessfully: 'Uspješno ažurirano.', + updateForEveryone: 'Ažuriranje za sve', updating: 'Ažuriranje', uploading: 'Prijenos', uploadingBulk: 'Prenosim {{current}} od {{total}}', diff --git a/packages/translations/src/languages/hu.ts b/packages/translations/src/languages/hu.ts index 8d0d3a29a7..4ae82303f8 100644 --- a/packages/translations/src/languages/hu.ts +++ b/packages/translations/src/languages/hu.ts @@ -317,6 +317,7 @@ export const huTranslations: DefaultTranslationsObject = { selectAll: 'Az összes kijelölése: {{count}} {{label}}', selectAllRows: 'Válassza ki az összes sort', selectedCount: '{{count}} {{label}} kiválasztva', + selectLabel: 'Válassza ki a(z) {{label}} opciót', selectValue: 'Válasszon ki egy értéket', showAllLabel: 'Mutasd az összes {{címke}}', sorryNotFound: 'Sajnáljuk – nincs semmi, ami megfelelne a kérésének.', @@ -344,7 +345,9 @@ export const huTranslations: DefaultTranslationsObject = { upcomingEvents: 'Közelgő események', updatedAt: 'Frissítve:', updatedCountSuccessfully: '{{count}} {{label}} sikeresen frissítve.', + updatedLabelSuccessfully: 'A(z) {{label}} sikeresen frissült.', updatedSuccessfully: 'Sikeresen frissítve.', + updateForEveryone: 'Frissítés mindenkinek', updating: 'Frissítés', uploading: 'Feltöltés', uploadingBulk: 'Feltöltés: {{current}} / {{total}}', diff --git a/packages/translations/src/languages/it.ts b/packages/translations/src/languages/it.ts index 258fbddb03..4191ce53ff 100644 --- a/packages/translations/src/languages/it.ts +++ b/packages/translations/src/languages/it.ts @@ -318,6 +318,7 @@ export const itTranslations: DefaultTranslationsObject = { selectAll: 'Seleziona tutto {{count}} {{label}}', selectAllRows: 'Seleziona tutte le righe', selectedCount: '{{count}} {{label}} selezionato', + selectLabel: 'Seleziona {{label}}', selectValue: 'Seleziona un valore', showAllLabel: 'Mostra tutti {{label}}', sorryNotFound: "Siamo spiacenti, non c'è nulla che corrisponda alla tua richiesta.", @@ -345,7 +346,9 @@ export const itTranslations: DefaultTranslationsObject = { upcomingEvents: 'Eventi Imminenti', updatedAt: 'Aggiornato il', updatedCountSuccessfully: '{{count}} {{label}} aggiornato con successo.', + updatedLabelSuccessfully: '{{label}} aggiornata con successo.', updatedSuccessfully: 'Aggiornato con successo.', + updateForEveryone: 'Aggiornamento per tutti', updating: 'Aggiornamento', uploading: 'Caricamento', uploadingBulk: 'Caricamento {{current}} di {{total}}', diff --git a/packages/translations/src/languages/ja.ts b/packages/translations/src/languages/ja.ts index 1f1b4bb63a..aa2dcb8866 100644 --- a/packages/translations/src/languages/ja.ts +++ b/packages/translations/src/languages/ja.ts @@ -314,6 +314,7 @@ export const jaTranslations: DefaultTranslationsObject = { selectAll: 'すべての{{count}}つの{{label}}を選択', selectAllRows: 'すべての行を選択します', selectedCount: '{{count}}つの{{label}}を選択中', + selectLabel: '{{label}}を選択してください', selectValue: '値を選択', showAllLabel: 'すべての{{label}}を表示する', sorryNotFound: '申し訳ありません。リクエストに対応する内容が見つかりませんでした。', @@ -341,7 +342,9 @@ export const jaTranslations: DefaultTranslationsObject = { upcomingEvents: '今後のイベント', updatedAt: '更新日', updatedCountSuccessfully: '{{count}}つの{{label}}を正常に更新しました。', + updatedLabelSuccessfully: '{{label}}の更新に成功しました。', updatedSuccessfully: '更新成功。', + updateForEveryone: '皆様への更新情報', updating: '更新中', uploading: 'アップロード中', uploadingBulk: '{{current}} / {{total}} をアップロード中', diff --git a/packages/translations/src/languages/ko.ts b/packages/translations/src/languages/ko.ts index fbfd8521ed..6d13bbcc3a 100644 --- a/packages/translations/src/languages/ko.ts +++ b/packages/translations/src/languages/ko.ts @@ -312,6 +312,7 @@ export const koTranslations: DefaultTranslationsObject = { selectAll: '{{count}}개 {{label}} 모두 선택', selectAllRows: '모든 행 선택', selectedCount: '{{count}}개의 {{label}} 선택됨', + selectLabel: '{{label}}을 선택하십시오.', selectValue: '값 선택', showAllLabel: '{{label}} 모두 표시', sorryNotFound: '죄송합니다. 요청과 일치하는 항목이 없습니다.', @@ -339,7 +340,9 @@ export const koTranslations: DefaultTranslationsObject = { upcomingEvents: '다가오는 이벤트', updatedAt: '업데이트 일시', updatedCountSuccessfully: '{{count}}개의 {{label}}을(를) 업데이트했습니다.', + updatedLabelSuccessfully: '{{label}}이(가) 성공적으로 업데이트되었습니다.', updatedSuccessfully: '성공적으로 업데이트되었습니다.', + updateForEveryone: '모두를 위한 업데이트', updating: '업데이트 중', uploading: '업로드 중', uploadingBulk: '{{current}} / {{total}} 업로드 중', diff --git a/packages/translations/src/languages/lt.ts b/packages/translations/src/languages/lt.ts index 911ab9dbbc..2afcd0dd5e 100644 --- a/packages/translations/src/languages/lt.ts +++ b/packages/translations/src/languages/lt.ts @@ -316,6 +316,7 @@ export const ltTranslations: DefaultTranslationsObject = { selectAll: 'Pasirinkite visus {{count}} {{label}}', selectAllRows: 'Pasirinkite visas eilutes', selectedCount: '{{count}} {{label}} pasirinkta', + selectLabel: 'Pasirinkite {{label}}', selectValue: 'Pasirinkite reikšmę', showAllLabel: 'Rodyti visus {{label}}', sorryNotFound: 'Atsiprašau - nėra nieko, atitinkančio jūsų užklausą.', @@ -343,7 +344,9 @@ export const ltTranslations: DefaultTranslationsObject = { upcomingEvents: 'Artimieji renginiai', updatedAt: 'Atnaujinta', updatedCountSuccessfully: '{{count}} {{label}} sėkmingai atnaujinta.', + updatedLabelSuccessfully: 'Sėkmingai atnaujinta {{label}}.', updatedSuccessfully: 'Sėkmingai atnaujinta.', + updateForEveryone: 'Atnaujinimas visiems', updating: 'Atnaujinimas', uploading: 'Įkeliama', uploadingBulk: 'Įkeliamas {{current}} iš {{total}}', diff --git a/packages/translations/src/languages/my.ts b/packages/translations/src/languages/my.ts index 1fab28b045..2150356137 100644 --- a/packages/translations/src/languages/my.ts +++ b/packages/translations/src/languages/my.ts @@ -317,6 +317,7 @@ export const myTranslations: DefaultTranslationsObject = { selectAll: '{{count}} {{label}} အားလုံးကို ရွေးပါ', selectAllRows: 'အားလုံးကိုရွေးချယ်ပါ', selectedCount: '{{count}} {{label}} ကို ရွေးထားသည်။', + selectLabel: 'Pilih {{label}}', selectValue: 'တစ်ခုခုကို ရွေးချယ်ပါ။', showAllLabel: 'Tunjukkan semua {{label}}', sorryNotFound: 'ဝမ်းနည်းပါသည်။ သင်ရှာနေတဲ့ဟာ ဒီမှာမရှိပါ။', @@ -346,7 +347,9 @@ export const myTranslations: DefaultTranslationsObject = { upcomingEvents: 'လာမည့် အစီအစဉ်များ', updatedAt: 'ပြင်ဆင်ခဲ့သည့်အချိန်', updatedCountSuccessfully: '{{count}} {{label}} ကို အောင်မြင်စွာ အပ်ဒိတ်လုပ်ခဲ့သည်။', + updatedLabelSuccessfully: 'Berjaya mengemas kini {{label}}.', updatedSuccessfully: 'အပ်ဒိတ်လုပ်ပြီးပါပြီ။', + updateForEveryone: 'အားလုံးအတွက် အပြောင်းအလဲ', updating: 'ပြင်ဆင်ရန်', uploading: 'တင်ပေးနေသည်', uploadingBulk: 'တင်နေသည် {{current}} ခု အမှတ်ဖြစ်သည် {{total}} ခုစုစုပေါင်းဖြင့်', diff --git a/packages/translations/src/languages/nb.ts b/packages/translations/src/languages/nb.ts index b37990f5e3..6a2e923c10 100644 --- a/packages/translations/src/languages/nb.ts +++ b/packages/translations/src/languages/nb.ts @@ -315,6 +315,7 @@ export const nbTranslations: DefaultTranslationsObject = { selectAll: 'Velg alle {{count}} {{label}}', selectAllRows: 'Velg alle rader', selectedCount: '{{count}} {{label}} valgt', + selectLabel: 'Velg {{label}}', selectValue: 'Velg en verdi', showAllLabel: 'Vis alle {{label}}', sorryNotFound: 'Beklager, det er ingenting som samsvarer med forespørselen din.', @@ -342,7 +343,9 @@ export const nbTranslations: DefaultTranslationsObject = { upcomingEvents: 'Kommende hendelser', updatedAt: 'Oppdatert', updatedCountSuccessfully: 'Oppdaterte {{count}} {{label}} vellykket.', + updatedLabelSuccessfully: 'Oppdatert {{label}} vellykket.', updatedSuccessfully: 'Oppdatert.', + updateForEveryone: 'Oppdatering for alle', updating: 'Oppdatering', uploading: 'Opplasting', uploadingBulk: 'Laster opp {{current}} av {{total}}', diff --git a/packages/translations/src/languages/nl.ts b/packages/translations/src/languages/nl.ts index e388533959..025c3ad040 100644 --- a/packages/translations/src/languages/nl.ts +++ b/packages/translations/src/languages/nl.ts @@ -318,6 +318,7 @@ export const nlTranslations: DefaultTranslationsObject = { selectAll: 'Alles selecteren {{count}} {{label}}', selectAllRows: 'Selecteer alle rijen', selectedCount: '{{count}} {{label}} geselecteerd', + selectLabel: 'Selecteer {{label}}', selectValue: 'Selecteer een waarde', showAllLabel: 'Toon alle {{label}}', sorryNotFound: 'Sorry, er is niets dat overeen komt met uw verzoek.', @@ -345,7 +346,9 @@ export const nlTranslations: DefaultTranslationsObject = { upcomingEvents: 'Aankomende Evenementen', updatedAt: 'Aangepast op', updatedCountSuccessfully: '{{count}} {{label}} succesvol bijgewerkt.', + updatedLabelSuccessfully: 'Met succes {{label}} bijgewerkt.', updatedSuccessfully: 'Succesvol aangepast.', + updateForEveryone: 'Update voor iedereen', updating: 'Bijwerken', uploading: 'Uploaden', uploadingBulk: 'Bezig met uploaden {{current}} van {{total}}', diff --git a/packages/translations/src/languages/pl.ts b/packages/translations/src/languages/pl.ts index 73dbb670e8..a67690df69 100644 --- a/packages/translations/src/languages/pl.ts +++ b/packages/translations/src/languages/pl.ts @@ -314,6 +314,7 @@ export const plTranslations: DefaultTranslationsObject = { selectAll: 'Wybierz wszystkie {{count}} {{label}}', selectAllRows: 'Wybierz wszystkie wiersze', selectedCount: 'Wybrano {{count}} {{label}}', + selectLabel: 'Wybierz {{label}}', selectValue: 'Wybierz wartość', showAllLabel: 'Pokaż wszystkie {{label}}', sorryNotFound: 'Przepraszamy — nie ma nic, co odpowiadałoby twojemu zapytaniu.', @@ -341,7 +342,9 @@ export const plTranslations: DefaultTranslationsObject = { upcomingEvents: 'Nadchodzące Wydarzenia', updatedAt: 'Data edycji', updatedCountSuccessfully: 'Pomyślnie zaktualizowano {{count}} {{label}}.', + updatedLabelSuccessfully: 'Pomyślnie zaktualizowano {{label}}.', updatedSuccessfully: 'Aktualizacja zakończona sukcesem.', + updateForEveryone: 'Aktualizacja dla wszystkich', updating: 'Aktualizacja', uploading: 'Przesyłanie', uploadingBulk: 'Przesyłanie {{current}} z {{total}}', diff --git a/packages/translations/src/languages/pt.ts b/packages/translations/src/languages/pt.ts index e7cbd2845d..48b383a954 100644 --- a/packages/translations/src/languages/pt.ts +++ b/packages/translations/src/languages/pt.ts @@ -315,6 +315,7 @@ export const ptTranslations: DefaultTranslationsObject = { selectAll: 'Selecione tudo {{count}} {{label}}', selectAllRows: 'Selecione todas as linhas', selectedCount: '{{count}} {{label}} selecionado', + selectLabel: 'Selecione {{label}}', selectValue: 'Selecione um valor', showAllLabel: 'Mostre todos {{label}}', sorryNotFound: 'Desculpe—não há nada que corresponda à sua requisição.', @@ -342,7 +343,9 @@ export const ptTranslations: DefaultTranslationsObject = { upcomingEvents: 'Próximos Eventos', updatedAt: 'Atualizado Em', updatedCountSuccessfully: 'Atualizado {{count}} {{label}} com sucesso.', + updatedLabelSuccessfully: '{{label}} atualizado com sucesso.', updatedSuccessfully: 'Atualizado com sucesso.', + updateForEveryone: 'Atualização para todos', updating: 'Atualizando', uploading: 'Fazendo upload', uploadingBulk: 'Carregando {{current}} de {{total}}', diff --git a/packages/translations/src/languages/ro.ts b/packages/translations/src/languages/ro.ts index ef8514a486..865f227a8e 100644 --- a/packages/translations/src/languages/ro.ts +++ b/packages/translations/src/languages/ro.ts @@ -318,6 +318,7 @@ export const roTranslations: DefaultTranslationsObject = { selectAll: 'Selectați toate {{count}} {{label}}', selectAllRows: 'Selectează toate rândurile', selectedCount: '{{count}} {{label}} selectate', + selectLabel: 'Selectați {{label}}', selectValue: 'Selectați o valoare', showAllLabel: 'Afișează toate {{eticheta}}', sorryNotFound: 'Ne pare rău - nu există nimic care să corespundă cu cererea dvs.', @@ -345,7 +346,9 @@ export const roTranslations: DefaultTranslationsObject = { upcomingEvents: 'Evenimente viitoare', updatedAt: 'Actualizat la', updatedCountSuccessfully: 'Actualizate {{count}} {{label}} cu succes.', + updatedLabelSuccessfully: '{{label}} actualizată cu succes.', updatedSuccessfully: 'Actualizat cu succes.', + updateForEveryone: 'Actualizare pentru toată lumea', updating: 'Actualizare', uploading: 'Încărcare', uploadingBulk: 'Încărcare {{current}} din {{total}}', diff --git a/packages/translations/src/languages/rs.ts b/packages/translations/src/languages/rs.ts index ca880f71e2..d36fec3f46 100644 --- a/packages/translations/src/languages/rs.ts +++ b/packages/translations/src/languages/rs.ts @@ -314,6 +314,7 @@ export const rsTranslations: DefaultTranslationsObject = { selectAll: 'Одаберите све {{count}} {{label}}', selectAllRows: 'Одаберите све редове', selectedCount: '{{count}} {{label}} одабрано', + selectLabel: 'Izaberite {{label}}', selectValue: 'Одабери вредност', showAllLabel: 'Прикажи све {{label}}', sorryNotFound: 'Нажалост, не постоји ништа што одговара вашем захтеву.', @@ -341,7 +342,9 @@ export const rsTranslations: DefaultTranslationsObject = { upcomingEvents: 'Predstojeći događaji', updatedAt: 'Ажурирано у', updatedCountSuccessfully: 'Успешно ажурирано {{count}} {{label}}.', + updatedLabelSuccessfully: 'Uspešno ažurirano {{label}}.', updatedSuccessfully: 'Успешно ажурирано.', + updateForEveryone: 'Ažuriranje za sve', updating: 'Ажурирање', uploading: 'Пренос', uploadingBulk: 'Отпремање {{current}} од {{total}}', diff --git a/packages/translations/src/languages/rsLatin.ts b/packages/translations/src/languages/rsLatin.ts index be7f527bbc..64c87c304a 100644 --- a/packages/translations/src/languages/rsLatin.ts +++ b/packages/translations/src/languages/rsLatin.ts @@ -315,6 +315,7 @@ export const rsLatinTranslations: DefaultTranslationsObject = { selectAll: 'Odaberite sve {{count}} {{label}}', selectAllRows: 'Odaberite sve redove', selectedCount: '{{count}} {{label}} odabrano', + selectLabel: 'Izaberite {{label}}', selectValue: 'Odaberi vrednost', showAllLabel: 'Prikaži sve {{label}}', sorryNotFound: 'Nažalost, ne postoji ništa što odgovara vašem zahtevu.', @@ -342,7 +343,9 @@ export const rsLatinTranslations: DefaultTranslationsObject = { upcomingEvents: 'Predstojeći događaji', updatedAt: 'Ažurirano u', updatedCountSuccessfully: 'Uspešno ažurirano {{count}} {{label}}.', + updatedLabelSuccessfully: 'Uspešno ažurirano {{label}}.', updatedSuccessfully: 'Uspešno ažurirano.', + updateForEveryone: 'Ažuriranje za sve', updating: 'Ažuriranje', uploading: 'Prenos', uploadingBulk: 'Otpremanje {{current}} od {{total}}', diff --git a/packages/translations/src/languages/ru.ts b/packages/translations/src/languages/ru.ts index 1cae34446b..d031100085 100644 --- a/packages/translations/src/languages/ru.ts +++ b/packages/translations/src/languages/ru.ts @@ -316,6 +316,7 @@ export const ruTranslations: DefaultTranslationsObject = { selectAll: 'Выбрать все {{count}} {{label}}', selectAllRows: 'Выбрать все строки', selectedCount: '{{count}} {{label}} выбрано', + selectLabel: 'Выберите {{label}}', selectValue: 'Выбрать значение', showAllLabel: 'Показать все {{label}}', sorryNotFound: 'К сожалению, ничего подходящего под ваш запрос нет.', @@ -345,7 +346,9 @@ export const ruTranslations: DefaultTranslationsObject = { upcomingEvents: 'Предстоящие события', updatedAt: 'Дата правки', updatedCountSuccessfully: 'Обновлено {{count}} {{label}} успешно.', + updatedLabelSuccessfully: 'Успешно обновлено {{label}}.', updatedSuccessfully: 'Успешно Обновлено.', + updateForEveryone: 'Обновление для всех', updating: 'Обновление', uploading: 'Загрузка', uploadingBulk: 'Загрузка {{current}} из {{total}}', diff --git a/packages/translations/src/languages/sk.ts b/packages/translations/src/languages/sk.ts index 067c08607e..ed6cee95fb 100644 --- a/packages/translations/src/languages/sk.ts +++ b/packages/translations/src/languages/sk.ts @@ -315,6 +315,7 @@ export const skTranslations: DefaultTranslationsObject = { selectAll: 'Vybrať všetko {{count}} {{label}}', selectAllRows: 'Vybrať všetky riadky', selectedCount: 'Vybrané {{count}} {{label}}', + selectLabel: 'Vyberte {{label}}', selectValue: 'Vybrať hodnotu', showAllLabel: 'Zobraziť všetky {{label}}', sorryNotFound: 'Je nám ľúto, ale neexistuje nič, čo by zodpovedalo vášmu požiadavku.', @@ -342,7 +343,9 @@ export const skTranslations: DefaultTranslationsObject = { upcomingEvents: 'Nadchádzajúce udalosti', updatedAt: 'Aktualizované v', updatedCountSuccessfully: 'Úspešne aktualizované {{count}} {{label}}.', + updatedLabelSuccessfully: 'Úspešne aktualizované {{label}}.', updatedSuccessfully: 'Úspešne aktualizované.', + updateForEveryone: 'Aktualizácia pre všetkých', updating: 'Aktualizácia', uploading: 'Nahrávanie', uploadingBulk: 'Nahrávanie {{current}} z {{total}}', diff --git a/packages/translations/src/languages/sl.ts b/packages/translations/src/languages/sl.ts index 81551dcd1e..cc716047b4 100644 --- a/packages/translations/src/languages/sl.ts +++ b/packages/translations/src/languages/sl.ts @@ -313,6 +313,7 @@ export const slTranslations: DefaultTranslationsObject = { selectAll: 'Izberi vse {{count}} {{label}}', selectAllRows: 'Izberi vse vrstice', selectedCount: '{{count}} {{label}} izbranih', + selectLabel: 'Izberite {{label}}', selectValue: 'Izberi vrednost', showAllLabel: 'Pokaži vse {{label}}', sorryNotFound: 'Oprostite - ničesar ni mogoče najti, kar bi ustrezalo vaši zahtevi.', @@ -340,7 +341,9 @@ export const slTranslations: DefaultTranslationsObject = { upcomingEvents: 'Prihajajoči dogodki', updatedAt: 'Posodobljeno', updatedCountSuccessfully: 'Uspešno posodobljeno {{count}} {{label}}.', + updatedLabelSuccessfully: '{{label}} uspešno posodobljen.', updatedSuccessfully: 'Uspešno posodobljeno.', + updateForEveryone: 'Posodobitev za vse', updating: 'Posodabljanje', uploading: 'Nalaganje', uploadingBulk: 'Nalaganje {{current}} od {{total}}', diff --git a/packages/translations/src/languages/sv.ts b/packages/translations/src/languages/sv.ts index 701e9e5803..009892e999 100644 --- a/packages/translations/src/languages/sv.ts +++ b/packages/translations/src/languages/sv.ts @@ -315,6 +315,7 @@ export const svTranslations: DefaultTranslationsObject = { selectAll: 'Välj alla {{count}} {{label}}', selectAllRows: 'Välj alla rader', selectedCount: '{{count}} {{label}} har valts', + selectLabel: 'Välj {{label}}', selectValue: 'Välj ett värde', showAllLabel: 'Visa alla {{label}}', sorryNotFound: 'Tyvärr–det finns inget som motsvarar din begäran.', @@ -342,7 +343,9 @@ export const svTranslations: DefaultTranslationsObject = { upcomingEvents: 'Kommande händelser', updatedAt: 'Uppdaterat', updatedCountSuccessfully: 'Uppdaterade {{count}} {{label}}', + updatedLabelSuccessfully: 'Uppdaterade {{label}} framgångsrikt.', updatedSuccessfully: 'Uppdaterades', + updateForEveryone: 'Uppdatering för alla', updating: 'Uppdaterar...', uploading: 'Laddar upp...', uploadingBulk: 'Laddar upp {{current}} av {{total}}', diff --git a/packages/translations/src/languages/th.ts b/packages/translations/src/languages/th.ts index 05586481b4..70173f55bb 100644 --- a/packages/translations/src/languages/th.ts +++ b/packages/translations/src/languages/th.ts @@ -310,6 +310,7 @@ export const thTranslations: DefaultTranslationsObject = { selectAll: 'เลือกทั้งหมด {{count}} {{label}}', selectAllRows: 'เลือกทุกแถว', selectedCount: 'เลือก {{count}} {{label}} แล้ว', + selectLabel: 'เลือก {{label}}', selectValue: 'เลือกค่า', showAllLabel: 'แสดง {{label}} ทั้งหมด', sorryNotFound: 'ขออภัย ไม่สามารถทำตามคำขอของคุณได้', @@ -337,7 +338,9 @@ export const thTranslations: DefaultTranslationsObject = { upcomingEvents: 'กิจกรรมที่จะถึง', updatedAt: 'แก้ไขเมื่อ', updatedCountSuccessfully: 'อัปเดต {{count}} {{label}} เรียบร้อยแล้ว', + updatedLabelSuccessfully: 'อัปเดต {{label}} สำเร็จแล้ว', updatedSuccessfully: 'แก้ไขสำเร็จ', + updateForEveryone: 'อัปเดตสำหรับทุกคน', updating: 'กำลังอัปเดต', uploading: 'กำลังอัปโหลด', uploadingBulk: 'อัปโหลด {{current}} จาก {{total}}', diff --git a/packages/translations/src/languages/tr.ts b/packages/translations/src/languages/tr.ts index 1e2f95fbed..56a9189799 100644 --- a/packages/translations/src/languages/tr.ts +++ b/packages/translations/src/languages/tr.ts @@ -318,6 +318,7 @@ export const trTranslations: DefaultTranslationsObject = { selectAll: "Tüm {{count}} {{label}}'ı seçin", selectAllRows: 'Tüm satırları seçin', selectedCount: '{{count}} {{label}} seçildi', + selectLabel: '{{label}} seçin', selectValue: 'Bir değer seçin', showAllLabel: 'Tüm {{label}} göster', sorryNotFound: 'Üzgünüz, isteğinizle eşleşen bir sonuç bulunamadı.', @@ -346,7 +347,9 @@ export const trTranslations: DefaultTranslationsObject = { upcomingEvents: 'Yaklaşan Etkinlikler', updatedAt: 'Güncellenme tarihi', updatedCountSuccessfully: '{{count}} {{label}} başarıyla güncellendi.', + updatedLabelSuccessfully: '{{label}} başarıyla güncellendi.', updatedSuccessfully: 'Başarıyla güncellendi.', + updateForEveryone: 'Herkes için güncelleme', updating: 'Güncelleniyor', uploading: 'Yükleniyor', uploadingBulk: "{{total}}'den {{current}} yükleniyor", diff --git a/packages/translations/src/languages/uk.ts b/packages/translations/src/languages/uk.ts index cedd1b40d4..57690d2f05 100644 --- a/packages/translations/src/languages/uk.ts +++ b/packages/translations/src/languages/uk.ts @@ -313,6 +313,7 @@ export const ukTranslations: DefaultTranslationsObject = { selectAll: 'Вибрати всі {{count}} {{label}}', selectAllRows: 'Обрати всі рядки', selectedCount: 'Обрано {{count}} {{label}}', + selectLabel: 'Виберіть {{label}}', selectValue: 'Обрати значення', showAllLabel: 'Показати всі {{label}}', sorryNotFound: 'Вибачте, немає нічого, що відповідало б Вашому запиту.', @@ -340,7 +341,9 @@ export const ukTranslations: DefaultTranslationsObject = { upcomingEvents: 'Майбутні події', updatedAt: 'Змінено', updatedCountSuccessfully: 'Успішно оновлено {{count}} {{label}}.', + updatedLabelSuccessfully: 'Успішно оновлено {{label}}.', updatedSuccessfully: 'Успішно відредаговано.', + updateForEveryone: 'Оновлення для всіх', updating: 'оновлення', uploading: 'завантаження', uploadingBulk: 'Завантаження {{current}} з {{total}}', diff --git a/packages/translations/src/languages/vi.ts b/packages/translations/src/languages/vi.ts index 71d63e61d0..10def88682 100644 --- a/packages/translations/src/languages/vi.ts +++ b/packages/translations/src/languages/vi.ts @@ -313,6 +313,7 @@ export const viTranslations: DefaultTranslationsObject = { selectAll: 'Chọn tất cả {{count}} {{label}}', selectAllRows: 'Chọn tất cả các hàng', selectedCount: 'Đã chọn {{count}} {{label}}', + selectLabel: 'Chọn {{label}}', selectValue: 'Chọn một giá trị', showAllLabel: 'Hiển thị tất cả {{label}}', sorryNotFound: 'Xin lỗi, không có kết quả nào tương ứng với request của bạn.', @@ -340,7 +341,9 @@ export const viTranslations: DefaultTranslationsObject = { upcomingEvents: 'Sự kiện sắp tới', updatedAt: 'Ngày cập nhật', updatedCountSuccessfully: 'Đã cập nhật thành công {{count}} {{label}}.', + updatedLabelSuccessfully: 'Đã cập nhật {{label}} thành công.', updatedSuccessfully: 'Cập nhật thành công.', + updateForEveryone: 'Cập nhật cho mọi người', updating: 'Đang cập nhật', uploading: 'Đang tải lên', uploadingBulk: 'Đang tải lên {{current}} trong tổng số {{total}}', diff --git a/packages/translations/src/languages/zh.ts b/packages/translations/src/languages/zh.ts index 4d0bccec28..5a460b2230 100644 --- a/packages/translations/src/languages/zh.ts +++ b/packages/translations/src/languages/zh.ts @@ -303,6 +303,7 @@ export const zhTranslations: DefaultTranslationsObject = { selectAll: '选择所有 {{count}} {{label}}', selectAllRows: '选择所有行', selectedCount: '已选择 {{count}} {{label}}', + selectLabel: '选择{{label}}', selectValue: '选择一个值', showAllLabel: '显示所有{{label}}', sorryNotFound: '对不起,没有与您的请求相对应的东西。', @@ -330,7 +331,9 @@ export const zhTranslations: DefaultTranslationsObject = { upcomingEvents: '即将到来的活动', updatedAt: '更新于', updatedCountSuccessfully: '已成功更新 {{count}} {{label}}。', + updatedLabelSuccessfully: '成功更新了 {{label}}。', updatedSuccessfully: '更新成功。', + updateForEveryone: '给大家的更新', updating: '更新中', uploading: '上传中', uploadingBulk: '正在上传{{current}},共{{total}}', diff --git a/packages/translations/src/languages/zhTw.ts b/packages/translations/src/languages/zhTw.ts index be386f234d..2dfb06f2ad 100644 --- a/packages/translations/src/languages/zhTw.ts +++ b/packages/translations/src/languages/zhTw.ts @@ -303,6 +303,7 @@ export const zhTwTranslations: DefaultTranslationsObject = { selectAll: '選擇所有 {{count}} 個 {{label}}', selectAllRows: '選擇所有行', selectedCount: '已選擇 {{count}} 個 {{label}}', + selectLabel: '選擇 {{label}}', selectValue: '選擇一個值', showAllLabel: '顯示所有{{label}}', sorryNotFound: '對不起,沒有找到您請求的東西。', @@ -330,7 +331,9 @@ export const zhTwTranslations: DefaultTranslationsObject = { upcomingEvents: '即將來臨的活動', updatedAt: '更新於', updatedCountSuccessfully: '已成功更新 {{count}} 個 {{label}}。', + updatedLabelSuccessfully: '成功更新了{{label}}。', updatedSuccessfully: '更新成功。', + updateForEveryone: '給所有人的更新', updating: '更新中', uploading: '上傳中', uploadingBulk: '正在上傳 {{current}} / {{total}}', diff --git a/packages/ui/src/elements/ColumnSelector/index.scss b/packages/ui/src/elements/ColumnSelector/index.scss index a194d629e5..d310aa1b8d 100644 --- a/packages/ui/src/elements/ColumnSelector/index.scss +++ b/packages/ui/src/elements/ColumnSelector/index.scss @@ -5,11 +5,10 @@ display: flex; flex-wrap: wrap; background: var(--theme-elevation-50); - padding: base(1) base(1) base(0.5); + padding: var(--base); + gap: calc(var(--base) / 2); &__column { - margin-right: base(0.5); - margin-bottom: base(0.5); background-color: transparent; box-shadow: 0 0 0 1px var(--theme-elevation-150); diff --git a/packages/ui/src/elements/ColumnSelector/index.tsx b/packages/ui/src/elements/ColumnSelector/index.tsx index 643e12eebd..e03cf154cd 100644 --- a/packages/ui/src/elements/ColumnSelector/index.tsx +++ b/packages/ui/src/elements/ColumnSelector/index.tsx @@ -8,9 +8,9 @@ import { FieldLabel } from '../../fields/FieldLabel/index.js' import { PlusIcon } from '../../icons/Plus/index.js' import { XIcon } from '../../icons/X/index.js' import { useEditDepth } from '../../providers/EditDepth/index.js' +import { useTableColumns } from '../../providers/TableColumns/index.js' import { DraggableSortable } from '../DraggableSortable/index.js' import { Pill } from '../Pill/index.js' -import { useTableColumns } from '../TableColumns/index.js' import './index.scss' const baseClass = 'column-selector' diff --git a/packages/ui/src/elements/DeleteDocument/index.tsx b/packages/ui/src/elements/DeleteDocument/index.tsx index 59e5d24fdf..a53f4e9664 100644 --- a/packages/ui/src/elements/DeleteDocument/index.tsx +++ b/packages/ui/src/elements/DeleteDocument/index.tsx @@ -60,8 +60,6 @@ export const DeleteDocument: React.FC = (props) => { const { startRouteTransition } = useRouteTransition() const { openModal } = useModal() - const titleToRender = titleFromProps || title || id - const modalSlug = `delete-${id}` const addDefaultError = useCallback(() => { @@ -163,7 +161,7 @@ export const DeleteDocument: React.FC = (props) => { t={t} variables={{ label: getTranslation(singularLabel, i18n), - title: titleToRender, + title: titleFromProps || title || id, }} /> } diff --git a/packages/ui/src/elements/DeleteMany/index.tsx b/packages/ui/src/elements/DeleteMany/index.tsx index a7fc4451dc..fc68d64877 100644 --- a/packages/ui/src/elements/DeleteMany/index.tsx +++ b/packages/ui/src/elements/DeleteMany/index.tsx @@ -4,6 +4,7 @@ import type { ClientCollectionConfig } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' import { useRouter, useSearchParams } from 'next/navigation.js' +import { mergeListSearchAndWhere } from 'payload/shared' import * as qs from 'qs-esm' import React, { useCallback } from 'react' import { toast } from 'sonner' @@ -14,7 +15,6 @@ import { useRouteCache } from '../../providers/RouteCache/index.js' import { SelectAllStatus, useSelection } from '../../providers/Selection/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { requests } from '../../utilities/api.js' -import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js' import { ConfirmationModal } from '../ConfirmationModal/index.js' import './index.scss' diff --git a/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx b/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx index 0795660eef..e2cf8f33a7 100644 --- a/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/DocumentDrawer/DrawerContent.tsx @@ -22,11 +22,11 @@ export const DocumentDrawerContent: React.FC = ({ drawerSlug, Header, initialData, - initialState, onDelete: onDeleteFromProps, onDuplicate: onDuplicateFromProps, onSave: onSaveFromProps, overrideEntityVisibility = true, + redirectAfterCreate, redirectAfterDelete, redirectAfterDuplicate, }) => { @@ -62,6 +62,7 @@ export const DocumentDrawerContent: React.FC = ({ initialData, locale, overrideEntityVisibility, + redirectAfterCreate, redirectAfterDelete: redirectAfterDelete !== undefined ? redirectAfterDelete : false, redirectAfterDuplicate: redirectAfterDuplicate !== undefined ? redirectAfterDuplicate : false, diff --git a/packages/ui/src/elements/DocumentDrawer/types.ts b/packages/ui/src/elements/DocumentDrawer/types.ts index 2e94640cf8..dee572ab66 100644 --- a/packages/ui/src/elements/DocumentDrawer/types.ts +++ b/packages/ui/src/elements/DocumentDrawer/types.ts @@ -12,8 +12,12 @@ export type DocumentDrawerProps = { readonly drawerSlug?: string readonly id?: null | number | string readonly initialData?: Data + /** + * @deprecated + */ readonly initialState?: FormState readonly overrideEntityVisibility?: boolean + readonly redirectAfterCreate?: boolean readonly redirectAfterDelete?: boolean readonly redirectAfterDuplicate?: boolean } & Pick & diff --git a/packages/ui/src/elements/EditMany/DrawerContent.tsx b/packages/ui/src/elements/EditMany/DrawerContent.tsx index 688ddd7ae8..7f7e3dc00f 100644 --- a/packages/ui/src/elements/EditMany/DrawerContent.tsx +++ b/packages/ui/src/elements/EditMany/DrawerContent.tsx @@ -5,7 +5,7 @@ import type { SelectType } from 'payload' import { useModal } from '@faceless-ui/modal' import { getTranslation } from '@payloadcms/translations' import { useRouter, useSearchParams } from 'next/navigation.js' -import { unflatten } from 'payload/shared' +import { mergeListSearchAndWhere, unflatten } from 'payload/shared' import * as qs from 'qs-esm' import React, { useCallback, useEffect, useMemo, useState } from 'react' @@ -27,7 +27,6 @@ import { SelectAllStatus, useSelection } from '../../providers/Selection/index.j import { useServerFunctions } from '../../providers/ServerFunctions/index.js' import { useTranslation } from '../../providers/Translation/index.js' import { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js' -import { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js' import { parseSearchParams } from '../../utilities/parseSearchParams.js' import { FieldSelect } from '../FieldSelect/index.js' import { baseClass, type EditManyProps } from './index.js' diff --git a/packages/ui/src/elements/ListControls/ActiveQueryPreset/index.scss b/packages/ui/src/elements/ListControls/ActiveQueryPreset/index.scss new file mode 100644 index 0000000000..e73be0f353 --- /dev/null +++ b/packages/ui/src/elements/ListControls/ActiveQueryPreset/index.scss @@ -0,0 +1,21 @@ +@import '../../../scss/styles'; + +@layer payload-default { + .active-query-preset { + &.pill { + max-width: 150px; + overflow: hidden; + } + + .pill__label { + display: flex; + align-items: center; + } + + &__label-text { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } +} diff --git a/packages/ui/src/elements/ListControls/ActiveQueryPreset/index.tsx b/packages/ui/src/elements/ListControls/ActiveQueryPreset/index.tsx new file mode 100644 index 0000000000..4e1be0e462 --- /dev/null +++ b/packages/ui/src/elements/ListControls/ActiveQueryPreset/index.tsx @@ -0,0 +1,65 @@ +'use client' +import type { QueryPreset } from 'payload' + +import { getTranslation } from '@payloadcms/translations' + +import { PeopleIcon } from '../../../icons/People/index.js' +import { XIcon } from '../../../icons/X/index.js' +import { useConfig } from '../../../providers/Config/index.js' +import { useTranslation } from '../../../providers/Translation/index.js' +import { Pill } from '../../Pill/index.js' +import './index.scss' + +const baseClass = 'active-query-preset' + +export function ActiveQueryPreset({ + activePreset, + openPresetListDrawer, + resetPreset, +}: { + activePreset: QueryPreset + openPresetListDrawer: () => void + resetPreset: () => Promise +}) { + const { i18n, t } = useTranslation() + const { getEntityConfig } = useConfig() + + const presetsConfig = getEntityConfig({ + collectionSlug: 'payload-query-presets', + }) + + return ( + { + openPresetListDrawer() + }} + pillStyle={activePreset ? 'always-white' : 'light'} + > + {activePreset?.isShared && } +
+ {activePreset?.title || + t('general:selectLabel', { label: getTranslation(presetsConfig.labels.singular, i18n) })} +
+ {activePreset ? ( +
{ + e.stopPropagation() + await resetPreset() + }} + onKeyDown={async (e) => { + e.stopPropagation() + await resetPreset() + }} + role="button" + tabIndex={0} + > + +
+ ) : null} +
+ ) +} diff --git a/packages/ui/src/elements/ListControls/index.scss b/packages/ui/src/elements/ListControls/index.scss index a369793ff8..0f3740a962 100644 --- a/packages/ui/src/elements/ListControls/index.scss +++ b/packages/ui/src/elements/ListControls/index.scss @@ -24,18 +24,14 @@ border-radius: 0; } + &__modified { + color: var(--theme-elevation-500); + } + &__buttons-wrap { display: flex; align-items: center; - gap: base(0.2); - - .pill { - background-color: var(--theme-elevation-150); - - &:hover { - background-color: var(--theme-elevation-100); - } - } + gap: 4px; } .column-selector, diff --git a/packages/ui/src/elements/ListControls/index.tsx b/packages/ui/src/elements/ListControls/index.tsx index 2deb787076..40dbed24e2 100644 --- a/packages/ui/src/elements/ListControls/index.tsx +++ b/packages/ui/src/elements/ListControls/index.tsx @@ -1,9 +1,11 @@ 'use client' -import type { ClientCollectionConfig, ResolvedFilterOptions, Where } from 'payload' import { useWindowInfo } from '@faceless-ui/window-info' import { getTranslation } from '@payloadcms/translations' -import React, { useEffect, useRef, useState } from 'react' +import { validateWhereQuery } from 'payload/shared' +import React, { Fragment, useEffect, useRef, useState } from 'react' + +import type { ListControlsProps } from './types.js' import { Popup, PopupList } from '../../elements/Popup/index.js' import { useUseTitleField } from '../../hooks/useUseAsTitle.js' @@ -17,36 +19,13 @@ import { ColumnSelector } from '../ColumnSelector/index.js' import { Pill } from '../Pill/index.js' import { SearchFilter } from '../SearchFilter/index.js' import { WhereBuilder } from '../WhereBuilder/index.js' -import validateWhereQuery from '../WhereBuilder/validateWhereQuery.js' +import { ActiveQueryPreset } from './ActiveQueryPreset/index.js' import { getTextFieldsToBeSearched } from './getTextFieldsToBeSearched.js' +import { useQueryPresets } from './useQueryPresets.js' import './index.scss' const baseClass = 'list-controls' -export type ListControlsProps = { - readonly beforeActions?: React.ReactNode[] - readonly collectionConfig: ClientCollectionConfig - readonly collectionSlug: string - /** - * @deprecated - * These are now handled by the `ListSelection` component - */ - readonly disableBulkDelete?: boolean - /** - * @deprecated - * These are now handled by the `ListSelection` component - */ - readonly disableBulkEdit?: boolean - readonly enableColumns?: boolean - readonly enableSort?: boolean - readonly handleSearchChange?: (search: string) => void - readonly handleSortChange?: (sort: string) => void - readonly handleWhereChange?: (where: Where) => void - readonly listMenuItems?: React.ReactNode[] - readonly renderedFilters?: Map - readonly resolvedFilterOptions?: Map -} - /** * The ListControls component is used to render the controls (search, filter, where) * for a collection's list view. You can find those directly above the table which lists @@ -57,15 +36,36 @@ export const ListControls: React.FC = (props) => { beforeActions, collectionConfig, collectionSlug, + disableQueryPresets, enableColumns = true, enableSort = false, - listMenuItems, + listMenuItems: listMenuItemsFromProps, + queryPreset: activePreset, + queryPresetPermissions, renderedFilters, resolvedFilterOptions, } = props + const { handleSearchChange, query } = useListQuery() + + const { + CreateNewPresetDrawer, + DeletePresetModal, + EditPresetDrawer, + hasModifiedPreset, + openPresetListDrawer, + PresetListDrawer, + queryPresetMenuItems, + resetPreset, + } = useQueryPresets({ + activePreset, + collectionSlug, + queryPresetPermissions, + }) + const titleField = useUseTitleField(collectionConfig) const { i18n, t } = useTranslation() + const { breakpoints: { s: smallBreak }, } = useWindowInfo() @@ -135,109 +135,131 @@ export const ListControls: React.FC = (props) => { } }, [t, listSearchableFields, i18n, searchLabel]) + let listMenuItems: React.ReactNode[] = listMenuItemsFromProps + + if ( + collectionConfig?.enableQueryPresets && + !disableQueryPresets && + queryPresetMenuItems?.length > 0 + ) { + // Cannot push or unshift into `listMenuItemsFromProps` as it will mutate the original array + listMenuItems = [ + ...queryPresetMenuItems, + listMenuItemsFromProps?.length > 0 ? : null, + ...(listMenuItemsFromProps || []), + ] + } + return ( -
-
- - { - return void handleSearchChange(search) - }} - // @ts-expect-error @todo: fix types - initialParams={query} - key={collectionSlug} - label={searchLabelTranslated.current} - /> -
-
- {!smallBreak && {beforeActions && beforeActions}} - {enableColumns && ( + +
+
+ + { + return void handleSearchChange(search) + }} + // @ts-expect-error @todo: fix types + initialParams={query} + key={collectionSlug} + label={searchLabelTranslated.current} + /> + {activePreset && hasModifiedPreset ? ( +
Modified
+ ) : null} +
+
+ {!smallBreak && {beforeActions && beforeActions}} + {enableColumns && ( + } + onClick={() => + setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined) + } + pillStyle="light" + > + {t('general:columns')} + + )} } - onClick={() => - setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined) - } + aria-controls={`${baseClass}-where`} + aria-expanded={visibleDrawer === 'where'} + className={`${baseClass}__toggle-where`} + icon={} + onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)} pillStyle="light" > - {t('general:columns')} + {t('general:filters')} - )} - } - onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)} - pillStyle="light" - > - {t('general:filters')} - - {enableSort && ( - } - onClick={() => setVisibleDrawer(visibleDrawer !== 'sort' ? 'sort' : undefined)} - pillStyle="light" - > - {t('general:sort')} - - )} - {listMenuItems && ( - } - className={`${baseClass}__popup`} - horizontalAlign="right" - size="large" - verticalAlign="bottom" - > - {listMenuItems.map((item) => item)} - - )} + {enableSort && ( + } + onClick={() => setVisibleDrawer(visibleDrawer !== 'sort' ? 'sort' : undefined)} + pillStyle="light" + > + {t('general:sort')} + + )} + {!disableQueryPresets && ( + + )} + {listMenuItems && Array.isArray(listMenuItems) && listMenuItems.length > 0 && ( + } + className={`${baseClass}__popup`} + horizontalAlign="right" + id="list-menu" + size="large" + verticalAlign="bottom" + > + + {listMenuItems.map((item, i) => ( + {item} + ))} + + + )} +
+ {enableColumns && ( + + + + )} + + +
- {enableColumns && ( - - - - )} - - - - {enableSort && ( - -

Sort Complex

- {/* */} -
- )} -
+ {PresetListDrawer} + {EditPresetDrawer} + {CreateNewPresetDrawer} + {DeletePresetModal} + ) } diff --git a/packages/ui/src/elements/ListControls/types.ts b/packages/ui/src/elements/ListControls/types.ts new file mode 100644 index 0000000000..a3ae40fca9 --- /dev/null +++ b/packages/ui/src/elements/ListControls/types.ts @@ -0,0 +1,34 @@ +import type { + ClientCollectionConfig, + QueryPreset, + ResolvedFilterOptions, + SanitizedCollectionPermission, + Where, +} from 'payload' + +export type ListControlsProps = { + readonly beforeActions?: React.ReactNode[] + readonly collectionConfig: ClientCollectionConfig + readonly collectionSlug: string + /** + * @deprecated + * These are now handled by the `ListSelection` component + */ + readonly disableBulkDelete?: boolean + /** + * @deprecated + * These are now handled by the `ListSelection` component + */ + readonly disableBulkEdit?: boolean + readonly disableQueryPresets?: boolean + readonly enableColumns?: boolean + readonly enableSort?: boolean + readonly handleSearchChange?: (search: string) => void + readonly handleSortChange?: (sort: string) => void + readonly handleWhereChange?: (where: Where) => void + readonly listMenuItems?: React.ReactNode[] + readonly queryPreset?: QueryPreset + readonly queryPresetPermissions?: SanitizedCollectionPermission + readonly renderedFilters?: Map + readonly resolvedFilterOptions?: Map +} diff --git a/packages/ui/src/elements/ListControls/useQueryPresets.tsx b/packages/ui/src/elements/ListControls/useQueryPresets.tsx new file mode 100644 index 0000000000..cafc1d041d --- /dev/null +++ b/packages/ui/src/elements/ListControls/useQueryPresets.tsx @@ -0,0 +1,326 @@ +import type { CollectionSlug, QueryPreset, SanitizedCollectionPermission } from 'payload' + +import { useModal } from '@faceless-ui/modal' +import { getTranslation } from '@payloadcms/translations' +import { transformColumnsToPreferences, transformColumnsToSearchParams } from 'payload/shared' +import React, { Fragment, useCallback, useMemo } from 'react' +import { toast } from 'sonner' + +import { useConfig } from '../../providers/Config/index.js' +import { useListQuery } from '../../providers/ListQuery/context.js' +import { useTranslation } from '../../providers/Translation/index.js' +import { ConfirmationModal } from '../ConfirmationModal/index.js' +import { useDocumentDrawer } from '../DocumentDrawer/index.js' +import { useListDrawer } from '../ListDrawer/index.js' +import { PopupList } from '../Popup/index.js' +import { PopupListGroupLabel } from '../Popup/PopupGroupLabel/index.js' +import { Translation } from '../Translation/index.js' + +const confirmDeletePresetModalSlug = 'confirm-delete-preset' + +const queryPresetsSlug = 'payload-query-presets' + +export const useQueryPresets = ({ + activePreset, + collectionSlug, + queryPresetPermissions, +}: { + activePreset: QueryPreset + collectionSlug: CollectionSlug + queryPresetPermissions: SanitizedCollectionPermission +}): { + CreateNewPresetDrawer: React.ReactNode + DeletePresetModal: React.ReactNode + EditPresetDrawer: React.ReactNode + hasModifiedPreset: boolean + openPresetListDrawer: () => void + PresetListDrawer: React.ReactNode + queryPresetMenuItems: React.ReactNode[] + resetPreset: () => Promise +} => { + const { modified, query, refineListData, setModified: setQueryModified } = useListQuery() + + const { i18n, t } = useTranslation() + const { openModal } = useModal() + + const { + config: { + routes: { api: apiRoute }, + }, + getEntityConfig, + } = useConfig() + + const presetConfig = getEntityConfig({ collectionSlug: queryPresetsSlug }) + + const [PresetDocumentDrawer, , { openDrawer: openDocumentDrawer }] = useDocumentDrawer({ + id: activePreset?.id, + collectionSlug: queryPresetsSlug, + }) + + const [ + CreateNewPresetDrawer, + , + { closeDrawer: closeCreateNewDrawer, openDrawer: openCreateNewDrawer }, + ] = useDocumentDrawer({ + collectionSlug: queryPresetsSlug, + }) + + const [ListDrawer, , { closeDrawer: closeListDrawer, openDrawer: openListDrawer }] = + useListDrawer({ + collectionSlugs: [queryPresetsSlug], + }) + + const handlePresetChange = useCallback( + async (preset: QueryPreset) => { + await refineListData( + { + columns: preset.columns ? transformColumnsToSearchParams(preset.columns) : undefined, + preset: preset.id, + where: preset.where, + }, + false, + ) + }, + [refineListData], + ) + + const resetQueryPreset = useCallback(async () => { + await refineListData( + { + columns: undefined, + preset: undefined, + where: undefined, + }, + false, + ) + }, [refineListData]) + + const handleDeletePreset = useCallback(async () => { + try { + await fetch(`${apiRoute}/${queryPresetsSlug}/${activePreset.id}`, { + method: 'DELETE', + }).then(async (res) => { + try { + const json = await res.json() + + if (res.status < 400) { + toast.success( + t('general:titleDeleted', { + label: getTranslation(presetConfig?.labels?.singular, i18n), + title: activePreset.title, + }), + ) + + await resetQueryPreset() + } else { + if (json.errors) { + json.errors.forEach((error) => toast.error(error.message)) + } else { + toast.error(t('error:deletingTitle', { title: activePreset.title })) + } + } + } catch (_err) { + toast.error(t('error:deletingTitle', { title: activePreset.title })) + } + }) + } catch (_err) { + toast.error(t('error:deletingTitle', { title: activePreset.title })) + } + }, [apiRoute, activePreset?.id, activePreset?.title, t, presetConfig, i18n, resetQueryPreset]) + + const saveCurrentChanges = useCallback(async () => { + try { + await fetch(`${apiRoute}/payload-query-presets/${activePreset.id}`, { + body: JSON.stringify({ + columns: transformColumnsToPreferences(query.columns), + where: query.where, + }), + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + method: 'PATCH', + }).then(async (res) => { + try { + const json = await res.json() + + if (res.status < 400) { + toast.success( + t('general:updatedLabelSuccessfully', { + label: getTranslation(presetConfig?.labels?.singular, i18n), + }), + ) + + setQueryModified(false) + } else { + if (json.errors) { + json.errors.forEach((error) => toast.error(error.message)) + } else { + toast.error(t('error:unknown')) + } + } + } catch (_err) { + toast.error(t('error:unknown')) + } + }) + } catch (_err) { + toast.error(t('error:unknown')) + } + }, [ + apiRoute, + activePreset?.id, + query.columns, + query.where, + t, + presetConfig?.labels?.singular, + i18n, + setQueryModified, + ]) + + // Memoize so that components aren't re-rendered on query and column changes + const queryPresetMenuItems = useMemo(() => { + const menuItems: React.ReactNode[] = [ + , + ] + + if (activePreset && modified) { + menuItems.push( + { + await refineListData( + { + columns: transformColumnsToSearchParams(activePreset.columns), + where: activePreset.where, + }, + false, + ) + }} + > + {t('general:reset')} + , + ) + + if (queryPresetPermissions.update) { + menuItems.push( + { + await saveCurrentChanges() + }} + > + {activePreset.isShared ? t('general:updateForEveryone') : t('general:save')} + , + ) + } + } + + menuItems.push( + { + openCreateNewDrawer() + }} + > + {t('general:createNew')} + , + ) + + if (activePreset && queryPresetPermissions?.delete) { + menuItems.push( + + openModal(confirmDeletePresetModalSlug)}> + {t('general:delete')} + + { + openDocumentDrawer() + }} + > + {t('general:edit')} + + , + ) + } + + return menuItems + }, [ + activePreset, + queryPresetPermissions?.delete, + queryPresetPermissions?.update, + openCreateNewDrawer, + openDocumentDrawer, + openModal, + saveCurrentChanges, + t, + refineListData, + modified, + presetConfig?.labels?.plural, + i18n, + ]) + + return { + CreateNewPresetDrawer: ( + { + closeCreateNewDrawer() + await handlePresetChange(doc as QueryPreset) + }} + redirectAfterCreate={false} + /> + ), + DeletePresetModal: ( + {children}, + }} + i18nKey="general:aboutToDelete" + t={t} + variables={{ + label: presetConfig?.labels?.singular, + title: activePreset?.title, + }} + /> + } + confirmingLabel={t('general:deleting')} + heading={t('general:confirmDeletion')} + modalSlug={confirmDeletePresetModalSlug} + onConfirm={handleDeletePreset} + /> + ), + EditPresetDrawer: ( + { + // setSelectedPreset(undefined) + }} + onDuplicate={async ({ doc }) => { + await handlePresetChange(doc as QueryPreset) + }} + onSave={async ({ doc }) => { + await handlePresetChange(doc as QueryPreset) + }} + /> + ), + hasModifiedPreset: modified, + openPresetListDrawer: openListDrawer, + PresetListDrawer: ( + { + closeListDrawer() + await handlePresetChange(doc as QueryPreset) + }} + /> + ), + queryPresetMenuItems, + resetPreset: resetQueryPreset, + } +} diff --git a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx index 3e74151707..27e6124a2e 100644 --- a/packages/ui/src/elements/ListDrawer/DrawerContent.tsx +++ b/packages/ui/src/elements/ListDrawer/DrawerContent.tsx @@ -2,6 +2,7 @@ import type { ListQuery } from 'payload' import { useModal } from '@faceless-ui/modal' +import { hoistQueryParamsToAnd } from 'payload/shared' import React, { useCallback, useEffect, useState } from 'react' import type { ListDrawerProps } from './types.js' @@ -10,7 +11,6 @@ import { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js' import { useEffectEvent } from '../../hooks/useEffectEvent.js' import { useConfig } from '../../providers/Config/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js' -import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js' import { ListDrawerContextProvider } from '../ListDrawer/Provider.js' import { LoadingOverlay } from '../Loading/index.js' import { type Option } from '../ReactSelect/index.js' @@ -18,6 +18,7 @@ import { type Option } from '../ReactSelect/index.js' export const ListDrawerContent: React.FC = ({ allowCreate = true, collectionSlugs, + disableQueryPresets, drawerSlug, enableRowSelections, filterOptions, @@ -91,6 +92,7 @@ export const ListDrawerContent: React.FC = ({ collectionSlug: slug, disableBulkDelete: true, disableBulkEdit: true, + disableQueryPresets, drawerSlug, enableRowSelections, overrideEntityVisibility, @@ -117,6 +119,7 @@ export const ListDrawerContent: React.FC = ({ enableRowSelections, filterOptions, overrideEntityVisibility, + disableQueryPresets, ], ) diff --git a/packages/ui/src/elements/ListDrawer/types.ts b/packages/ui/src/elements/ListDrawer/types.ts index 73f86928de..7d37ca1155 100644 --- a/packages/ui/src/elements/ListDrawer/types.ts +++ b/packages/ui/src/elements/ListDrawer/types.ts @@ -7,6 +7,7 @@ import type { ListDrawerContextProps } from './Provider.js' export type ListDrawerProps = { readonly allowCreate?: boolean readonly collectionSlugs: SanitizedCollectionConfig['slug'][] + readonly disableQueryPresets?: boolean readonly drawerSlug?: string readonly enableRowSelections?: boolean readonly filterOptions?: FilterOptionsResult @@ -28,10 +29,8 @@ export type UseListDrawer = (args: { selectedCollection?: SanitizedCollectionConfig['slug'] uploads?: boolean // finds all collections with upload: true }) => [ - React.FC< - Pick - >, // drawer - React.FC>, // toggler + React.FC>, + React.FC>, { closeDrawer: () => void collectionSlugs: SanitizedCollectionConfig['slug'][] diff --git a/packages/ui/src/elements/Pill/index.scss b/packages/ui/src/elements/Pill/index.scss index 3b946f806b..f370546f3b 100644 --- a/packages/ui/src/elements/Pill/index.scss +++ b/packages/ui/src/elements/Pill/index.scss @@ -73,22 +73,28 @@ background: var(--theme-elevation-0); &.pill--has-action { - &:hover { - background: var(--theme-elevation-100); - } - + &:hover, &:active { background: var(--theme-elevation-100); } } } + &--style-always-white { + background: var(--theme-elevation-850); + color: var(--theme-elevation-0); + + &.pill--has-action { + &:hover, + &:active { + background: var(--theme-elevation-750); + } + } + } + &--style-light { &.pill--has-action { - &:hover { - background: var(--theme-elevation-100); - } - + &:hover, &:active { background: var(--theme-elevation-100); } @@ -139,4 +145,21 @@ line-height: 18px; } } + + html[data-theme='light'] { + .pill { + &--style-always-white { + background: var(--theme-elevation-0); + color: var(--theme-elevation-800); + border: 1px solid var(--theme-elevation-100); + + &.pill--has-action { + &:hover, + &:active { + background: var(--theme-elevation-100); + } + } + } + } + } } diff --git a/packages/ui/src/elements/Pill/index.tsx b/packages/ui/src/elements/Pill/index.tsx index 80e9085ca0..e7e5d310a3 100644 --- a/packages/ui/src/elements/Pill/index.tsx +++ b/packages/ui/src/elements/Pill/index.tsx @@ -20,7 +20,15 @@ export type PillProps = { icon?: React.ReactNode id?: string onClick?: () => void - pillStyle?: 'dark' | 'error' | 'light' | 'light-gray' | 'success' | 'warning' | 'white' + pillStyle?: + | 'always-white' + | 'dark' + | 'error' + | 'light' + | 'light-gray' + | 'success' + | 'warning' + | 'white' rounded?: boolean size?: 'medium' | 'small' to?: string @@ -64,6 +72,7 @@ const DraggablePill: React.FC = (props) => { const StaticPill: React.FC = (props) => { const { + id, alignIcon = 'right', 'aria-checked': ariaChecked, 'aria-controls': ariaControls, @@ -101,6 +110,7 @@ const StaticPill: React.FC = (props) => { if (onClick && !to) { Element = 'button' } + if (to) { Element = Link } @@ -114,6 +124,7 @@ const StaticPill: React.FC = (props) => { aria-label={ariaLabel} className={classes} href={to || null} + id={id} onClick={onClick} type={Element === 'button' ? 'button' : undefined} > diff --git a/packages/ui/src/elements/Popup/PopupButtonList/index.tsx b/packages/ui/src/elements/Popup/PopupButtonList/index.tsx index b29f07754c..f9f4d4f4e6 100644 --- a/packages/ui/src/elements/Popup/PopupButtonList/index.tsx +++ b/packages/ui/src/elements/Popup/PopupButtonList/index.tsx @@ -8,6 +8,9 @@ import './index.scss' const baseClass = 'popup-button-list' +export { PopupListDivider as Divider } from '../PopupDivider/index.js' +export { PopupListGroupLabel as GroupLabel } from '../PopupGroupLabel/index.js' + export const ButtonGroup: React.FC<{ buttonSize?: 'default' | 'small' children: React.ReactNode diff --git a/packages/ui/src/elements/Popup/PopupDivider/index.scss b/packages/ui/src/elements/Popup/PopupDivider/index.scss new file mode 100644 index 0000000000..943ee639cf --- /dev/null +++ b/packages/ui/src/elements/Popup/PopupDivider/index.scss @@ -0,0 +1,10 @@ +@import '../../../scss/styles.scss'; + +@layer payload-default { + .popup-divider { + width: 100%; + height: 1px; + background-color: var(--theme-elevation-150); + border: none; + } +} diff --git a/packages/ui/src/elements/Popup/PopupDivider/index.tsx b/packages/ui/src/elements/Popup/PopupDivider/index.tsx new file mode 100644 index 0000000000..bd3e890522 --- /dev/null +++ b/packages/ui/src/elements/Popup/PopupDivider/index.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +import './index.scss' + +const baseClass = 'popup-divider' + +export const PopupListDivider: React.FC = () => { + return
+} diff --git a/packages/ui/src/elements/Popup/PopupGroupLabel/index.scss b/packages/ui/src/elements/Popup/PopupGroupLabel/index.scss new file mode 100644 index 0000000000..ff3e14c95f --- /dev/null +++ b/packages/ui/src/elements/Popup/PopupGroupLabel/index.scss @@ -0,0 +1,8 @@ +@import '../../../scss/styles.scss'; + +@layer payload-default { + .popup-list-group-label { + color: var(--theme-elevation-500); + padding: 0 var(--list-button-padding); + } +} diff --git a/packages/ui/src/elements/Popup/PopupGroupLabel/index.tsx b/packages/ui/src/elements/Popup/PopupGroupLabel/index.tsx new file mode 100644 index 0000000000..00361be73c --- /dev/null +++ b/packages/ui/src/elements/Popup/PopupGroupLabel/index.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +import './index.scss' + +const baseClass = 'popup-list-group-label' + +export const PopupListGroupLabel: React.FC<{ + label: string +}> = ({ label }) => { + return

{label}

+} diff --git a/packages/ui/src/elements/Popup/index.tsx b/packages/ui/src/elements/Popup/index.tsx index 6ffb7e5257..afa2e41ec9 100644 --- a/packages/ui/src/elements/Popup/index.tsx +++ b/packages/ui/src/elements/Popup/index.tsx @@ -7,8 +7,8 @@ import { useWindowInfo } from '@faceless-ui/window-info' import React, { useCallback, useEffect, useRef, useState } from 'react' import { useIntersect } from '../../hooks/useIntersect.js' -import './index.scss' import { PopupTrigger } from './PopupTrigger/index.js' +import './index.scss' const baseClass = 'popup' @@ -60,6 +60,7 @@ export const Popup: React.FC = (props) => { verticalAlign: verticalAlignFromProps = 'top', } = props const { height: windowHeight, width: windowWidth } = useWindowInfo() + const [intersectionRef, intersectionEntry] = useIntersect({ root: boundingRef?.current || null, rootMargin: '-100px 0px 0px 0px', @@ -212,7 +213,7 @@ export const Popup: React.FC = (props) => {
{render && render({ close: () => setActive(false) })} - {children && children} + {children}
diff --git a/packages/ui/src/elements/QueryPresets/cells/AccessCell/index.tsx b/packages/ui/src/elements/QueryPresets/cells/AccessCell/index.tsx new file mode 100644 index 0000000000..d5d0cae8f0 --- /dev/null +++ b/packages/ui/src/elements/QueryPresets/cells/AccessCell/index.tsx @@ -0,0 +1,30 @@ +import type { DefaultCellComponentProps } from 'payload' +import type { JSX } from 'react' + +import { toWords } from 'payload/shared' +import React, { Fragment } from 'react' + +export const QueryPresetsAccessCell: React.FC = ({ cellData }) => { + // first sort the operations in the order they should be displayed + const operations = ['read', 'update', 'delete'] + + return ( +

+ {operations.reduce((acc, operation, index) => { + const operationData = (cellData as JSON)?.[operation] + + if (operationData && operationData.constraint) { + acc.push( + + + {toWords(operation)}: {toWords(operationData.constraint)} + + {index !== operations.length - 1 && ', '} + , + ) + } + return acc + }, [] as JSX.Element[])} +

+ ) +} diff --git a/packages/ui/src/elements/QueryPresets/cells/ColumnsCell/index.scss b/packages/ui/src/elements/QueryPresets/cells/ColumnsCell/index.scss new file mode 100644 index 0000000000..02ff5f8084 --- /dev/null +++ b/packages/ui/src/elements/QueryPresets/cells/ColumnsCell/index.scss @@ -0,0 +1,9 @@ +@import '../../../../scss/styles'; + +@layer payload-default { + .query-preset-columns-cell { + display: flex; + flex-wrap: wrap; + gap: 4px; + } +} diff --git a/packages/ui/src/elements/QueryPresets/cells/ColumnsCell/index.tsx b/packages/ui/src/elements/QueryPresets/cells/ColumnsCell/index.tsx new file mode 100644 index 0000000000..efd14ca6a5 --- /dev/null +++ b/packages/ui/src/elements/QueryPresets/cells/ColumnsCell/index.tsx @@ -0,0 +1,32 @@ +import type { ColumnPreference, DefaultCellComponentProps } from 'payload' + +import { toWords, transformColumnsToSearchParams } from 'payload/shared' +import React from 'react' + +import { Pill } from '../../../Pill/index.js' +import './index.scss' + +const baseClass = 'query-preset-columns-cell' + +export const QueryPresetsColumnsCell: React.FC = ({ cellData }) => { + return ( +
+ {cellData + ? transformColumnsToSearchParams(cellData as ColumnPreference[]).map((column, i) => { + const isColumnActive = !column.startsWith('-') + + // to void very lengthy cells, only display the active columns + if (!isColumnActive) { + return null + } + + return ( + + {toWords(column)} + + ) + }) + : 'No columns selected'} +
+ ) +} diff --git a/packages/ui/src/elements/QueryPresets/cells/WhereCell/index.tsx b/packages/ui/src/elements/QueryPresets/cells/WhereCell/index.tsx new file mode 100644 index 0000000000..d7c875aee1 --- /dev/null +++ b/packages/ui/src/elements/QueryPresets/cells/WhereCell/index.tsx @@ -0,0 +1,33 @@ +import type { DefaultCellComponentProps, Where } from 'payload' + +import { toWords } from 'payload/shared' +import React from 'react' + +/** @todo: improve this */ +const transformWhereToNaturalLanguage = (where: Where): string => { + if (where.or && where.or.length > 0 && where.or[0].and && where.or[0].and.length > 0) { + const orQuery = where.or[0] + const andQuery = orQuery?.and?.[0] + + if (!andQuery) { + return 'No where query' + } + + const key = Object.keys(andQuery)[0] + + if (!andQuery[key]) { + return 'No where query' + } + + const operator = Object.keys(andQuery[key])[0] + const value = andQuery[key][operator] + + return `${toWords(key)} ${operator} ${toWords(value)}` + } + + return '' +} + +export const QueryPresetsWhereCell: React.FC = ({ cellData }) => { + return
{cellData ? transformWhereToNaturalLanguage(cellData) : 'No where query'}
+} diff --git a/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.scss b/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.scss new file mode 100644 index 0000000000..37f31f036c --- /dev/null +++ b/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.scss @@ -0,0 +1,17 @@ +@import '../../../../scss/styles'; + +@layer payload-default { + .query-preset-columns-field { + .field-label { + margin-bottom: calc(var(--base) / 2); + } + + .value-wrapper { + background-color: var(--theme-elevation-50); + padding: var(--base); + display: flex; + flex-wrap: wrap; + gap: calc(var(--base) / 2); + } + } +} diff --git a/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx b/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx new file mode 100644 index 0000000000..78570d6671 --- /dev/null +++ b/packages/ui/src/elements/QueryPresets/fields/ColumnsField/index.tsx @@ -0,0 +1,36 @@ +'use client' +import type { ColumnPreference, JSONFieldClientComponent } from 'payload' + +import { toWords, transformColumnsToSearchParams } from 'payload/shared' +import React from 'react' + +import { FieldLabel } from '../../../../fields/FieldLabel/index.js' +import { useField } from '../../../../forms/useField/index.js' +import { Pill } from '../../../Pill/index.js' +import './index.scss' + +export const QueryPresetsColumnField: JSONFieldClientComponent = ({ + field: { label, required }, + path, +}) => { + const { value } = useField({ path }) + + return ( +
+ +
+ {value + ? transformColumnsToSearchParams(value as ColumnPreference[]).map((column, i) => { + const isColumnActive = !column.startsWith('-') + + return ( + + {toWords(column)} + + ) + }) + : 'No columns selected'} +
+
+ ) +} diff --git a/packages/ui/src/elements/QueryPresets/fields/WhereField/index.scss b/packages/ui/src/elements/QueryPresets/fields/WhereField/index.scss new file mode 100644 index 0000000000..2376aad439 --- /dev/null +++ b/packages/ui/src/elements/QueryPresets/fields/WhereField/index.scss @@ -0,0 +1,23 @@ +@import '../../../../scss/styles'; + +@layer payload-default { + .query-preset-where-field { + .field-label { + margin-bottom: calc(var(--base) / 2); + } + + .value-wrapper { + background-color: var(--theme-elevation-50); + padding: var(--base); + } + } + + .query-preset-where-field { + .pill { + &--style-always-white { + background: var(--theme-elevation-250); + color: var(--theme-elevation-1000); + } + } + } +} diff --git a/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx b/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx new file mode 100644 index 0000000000..564cbe82b1 --- /dev/null +++ b/packages/ui/src/elements/QueryPresets/fields/WhereField/index.tsx @@ -0,0 +1,114 @@ +'use client' +import type { JSONFieldClientComponent, Where } from 'payload' + +import { getTranslation } from '@payloadcms/translations' +import { toWords } from 'payload/shared' +import React from 'react' + +import { FieldLabel } from '../../../../fields/FieldLabel/index.js' +import { useField } from '../../../../forms/useField/index.js' +import { useConfig } from '../../../../providers/Config/index.js' +import { useListQuery } from '../../../../providers/ListQuery/index.js' +import { useTranslation } from '../../../../providers/Translation/index.js' +import { Pill } from '../../../Pill/index.js' +import './index.scss' + +/** @todo: improve this */ +const transformWhereToNaturalLanguage = ( + where: Where, + collectionLabel: string, +): React.ReactNode => { + if (!where) { + return null + } + + const renderCondition = (condition: any): React.ReactNode => { + const key = Object.keys(condition)[0] + + if (!condition[key]) { + return 'No where query' + } + + const operator = Object.keys(condition[key])[0] + let value = condition[key][operator] + + // TODO: this is not right, but works for now at least. + // Ideally we look up iterate _fields_ so we know the type of the field + // Currently, we're only iterating over the `where` field's value, so we don't know the type + if (typeof value === 'object') { + try { + value = new Date(value).toLocaleDateString() + } catch (_err) { + value = 'Unknown error has occurred' + } + } + + return ( + + {toWords(key)} {operator} {toWords(value)} + + ) + } + + const renderWhere = (where: Where, collectionLabel: string): React.ReactNode => { + if (where.or && where.or.length > 0) { + return ( +
+ {where.or.map((orCondition, orIndex) => ( + + {orCondition.and && orCondition.and.length > 0 ? ( +
+ {orIndex === 0 && ( + {`Filter ${collectionLabel} where `} + )} + {orIndex > 0 && or } + {orCondition.and.map((andCondition, andIndex) => ( + + {renderCondition(andCondition)} + {andIndex < orCondition.and.length - 1 && ( + and + )} + + ))} +
+ ) : ( + renderCondition(orCondition) + )} +
+ ))} +
+ ) + } + + return renderCondition(where) + } + + return renderWhere(where, collectionLabel) +} + +export const QueryPresetsWhereField: JSONFieldClientComponent = ({ + field: { label, required }, + path, +}) => { + const { value } = useField({ path }) + const { collectionSlug } = useListQuery() + const { getEntityConfig } = useConfig() + + const collectionConfig = getEntityConfig({ collectionSlug }) + + const { i18n } = useTranslation() + + return ( +
+ +
+ {value + ? transformWhereToNaturalLanguage( + value as Where, + getTranslation(collectionConfig.labels.plural, i18n), + ) + : 'No where query'} +
+
+ ) +} diff --git a/packages/ui/src/elements/RelationshipTable/cells/DrawerLink/index.tsx b/packages/ui/src/elements/RelationshipTable/cells/DrawerLink/index.tsx index 8219f994c2..1c07f3a649 100644 --- a/packages/ui/src/elements/RelationshipTable/cells/DrawerLink/index.tsx +++ b/packages/ui/src/elements/RelationshipTable/cells/DrawerLink/index.tsx @@ -5,9 +5,9 @@ import React, { useCallback } from 'react' import type { DocumentDrawerProps } from '../../../DocumentDrawer/types.js' import { EditIcon } from '../../../../icons/Edit/index.js' +import { useCellProps } from '../../../../providers/TableColumns/RenderDefaultCell/index.js' import { useDocumentDrawer } from '../../../DocumentDrawer/index.js' import { DefaultCell } from '../../../Table/DefaultCell/index.js' -import { useCellProps } from '../../../TableColumns/RenderDefaultCell/index.js' import './index.scss' export const DrawerLink: React.FC<{ diff --git a/packages/ui/src/elements/RelationshipTable/index.tsx b/packages/ui/src/elements/RelationshipTable/index.tsx index 76795c6c1e..a6588961cf 100644 --- a/packages/ui/src/elements/RelationshipTable/index.tsx +++ b/packages/ui/src/elements/RelationshipTable/index.tsx @@ -8,7 +8,7 @@ import { type PaginatedDocs, type Where, } from 'payload' -import { transformColumnsToPreferences } from 'payload/shared' +import { hoistQueryParamsToAnd, transformColumnsToPreferences } from 'payload/shared' import React, { Fragment, useCallback, useEffect, useState } from 'react' import type { DocumentDrawerProps } from '../DocumentDrawer/types.js' @@ -22,17 +22,16 @@ import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' import { ListQueryProvider } from '../../providers/ListQuery/index.js' import { useServerFunctions } from '../../providers/ServerFunctions/index.js' +import { TableColumnsProvider } from '../../providers/TableColumns/index.js' import { useTranslation } from '../../providers/Translation/index.js' -import { hoistQueryParamsToAnd } from '../../utilities/mergeListSearchAndWhere.js' import { AnimateHeight } from '../AnimateHeight/index.js' -import './index.scss' import { ColumnSelector } from '../ColumnSelector/index.js' import { useDocumentDrawer } from '../DocumentDrawer/index.js' import { Popup, PopupList } from '../Popup/index.js' import { RelationshipProvider } from '../Table/RelationshipProvider/index.js' -import { TableColumnsProvider } from '../TableColumns/index.js' import { DrawerLink } from './cells/DrawerLink/index.js' import { RelationshipTablePagination } from './Pagination.js' +import './index.scss' const baseClass = 'relationship-table' diff --git a/packages/ui/src/elements/WhereBuilder/Condition/index.tsx b/packages/ui/src/elements/WhereBuilder/Condition/index.tsx index 644ee39215..20ca8ed2e1 100644 --- a/packages/ui/src/elements/WhereBuilder/Condition/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/Condition/index.tsx @@ -26,8 +26,8 @@ import { useEffectEvent } from '../../../hooks/useEffectEvent.js' import { useTranslation } from '../../../providers/Translation/index.js' import { Button } from '../../Button/index.js' import { ReactSelect } from '../../ReactSelect/index.js' -import { DefaultFilter } from './DefaultFilter/index.js' import './index.scss' +import { DefaultFilter } from './DefaultFilter/index.js' const baseClass = 'condition' diff --git a/packages/ui/src/elements/WhereBuilder/index.tsx b/packages/ui/src/elements/WhereBuilder/index.tsx index d9b4b21bda..7bf6ed3564 100644 --- a/packages/ui/src/elements/WhereBuilder/index.tsx +++ b/packages/ui/src/elements/WhereBuilder/index.tsx @@ -2,6 +2,7 @@ import type { Operator } from 'payload' import { getTranslation } from '@payloadcms/translations' +import { transformWhereQuery, validateWhereQuery } from 'payload/shared' import React, { useMemo } from 'react' import type { AddCondition, RemoveCondition, UpdateCondition, WhereBuilderProps } from './types.js' @@ -12,8 +13,6 @@ import { Button } from '../Button/index.js' import { Condition } from './Condition/index.js' import fieldTypes from './field-types.js' import { reduceFields } from './reduceFields.js' -import { transformWhereQuery } from './transformWhereQuery.js' -import validateWhereQuery from './validateWhereQuery.js' import './index.scss' const baseClass = 'where-builder' @@ -84,22 +83,29 @@ export const WhereBuilder: React.FC = (props) => { const updateCondition: UpdateCondition = React.useCallback( async ({ andIndex, field, operator: incomingOperator, orIndex, value: valueArg }) => { - const existingRowCondition = conditions[orIndex].and[andIndex] + const existingCondition = conditions[orIndex].and[andIndex] const defaults = fieldTypes[field.field.type] const operator = incomingOperator || defaults.operators[0].value - if (typeof existingRowCondition === 'object' && field.value) { - const value = valueArg ?? existingRowCondition?.[operator] + if (typeof existingCondition === 'object' && field.value) { + const value = valueArg ?? existingCondition?.[operator] - const newRowCondition = { - [field.value]: { [operator]: value }, + const valueChanged = value !== existingCondition?.[field.value]?.[operator] + + const operatorChanged = + operator !== Object.keys(existingCondition?.[field.value] || {})?.[0] + + if (valueChanged || operatorChanged) { + const newRowCondition = { + [field.value]: { [operator]: value }, + } + + const newConditions = [...conditions] + newConditions[orIndex].and[andIndex] = newRowCondition + + await handleWhereChange({ or: newConditions }) } - - const newConditions = [...conditions] - newConditions[orIndex].and[andIndex] = newRowCondition - - await handleWhereChange({ or: newConditions }) } }, [conditions, handleWhereChange], diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 1d1a53d725..c36770f883 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -22,6 +22,13 @@ export { useEffectEvent } from '../../hooks/useEffectEvent.js' export { useUseTitleField } from '../../hooks/useUseAsTitle.js' +// query preset elements +export { QueryPresetsColumnsCell } from '../../elements/QueryPresets/cells/ColumnsCell/index.js' +export { QueryPresetsWhereCell } from '../../elements/QueryPresets/cells/WhereCell/index.js' +export { QueryPresetsAccessCell } from '../../elements/QueryPresets/cells/AccessCell/index.js' +export { QueryPresetsColumnField } from '../../elements/QueryPresets/fields/ColumnsField/index.js' +export { QueryPresetsWhereField } from '../../elements/QueryPresets/fields/WhereField/index.js' + // elements export { ConfirmationModal } from '../../elements/ConfirmationModal/index.js' export type { OnCancel } from '../../elements/ConfirmationModal/index.js' @@ -29,11 +36,11 @@ export { Link } from '../../elements/Link/index.js' export { LeaveWithoutSaving } from '../../elements/LeaveWithoutSaving/index.js' export { DocumentTakeOver } from '../../elements/DocumentTakeOver/index.js' export { DocumentLocked } from '../../elements/DocumentLocked/index.js' -export { TableColumnsProvider, useTableColumns } from '../../elements/TableColumns/index.js' +export { TableColumnsProvider, useTableColumns } from '../../providers/TableColumns/index.js' export { RenderDefaultCell, useCellProps, -} from '../../elements/TableColumns/RenderDefaultCell/index.js' +} from '../../providers/TableColumns/RenderDefaultCell/index.js' export { Translation } from '../../elements/Translation/index.js' export { default as DatePicker } from '../../elements/DatePicker/DatePicker.js' diff --git a/packages/ui/src/exports/shared/index.ts b/packages/ui/src/exports/shared/index.ts index a5268a3fdf..cfde4aac69 100644 --- a/packages/ui/src/exports/shared/index.ts +++ b/packages/ui/src/exports/shared/index.ts @@ -1,6 +1,3 @@ -// IMPORTANT: the shared.ts file CANNOT contain any Server Components _that import client components_. -export { filterFields } from '../../elements/TableColumns/filterFields.js' -export { getInitialColumns } from '../../elements/TableColumns/getInitialColumns.js' export { Translation } from '../../elements/Translation/index.js' export { withMergedProps } from '../../elements/withMergedProps/index.js' // cannot be within a 'use client', thus we export this from shared export { WithServerSideProps } from '../../elements/WithServerSideProps/index.js' @@ -8,6 +5,9 @@ export { mergeFieldStyles } from '../../fields/mergeFieldStyles.js' export { reduceToSerializableFields } from '../../forms/Form/reduceToSerializableFields.js' export { PayloadIcon } from '../../graphics/Icon/index.js' export { PayloadLogo } from '../../graphics/Logo/index.js' +// IMPORTANT: the shared.ts file CANNOT contain any Server Components _that import client components_. +export { filterFields } from '../../providers/TableColumns/filterFields.js' +export { getInitialColumns } from '../../providers/TableColumns/getInitialColumns.js' export { abortAndIgnore, handleAbortRef } from '../../utilities/abortAndIgnore.js' export { requests } from '../../utilities/api.js' export { findLocaleFromCode } from '../../utilities/findLocaleFromCode.js' @@ -26,5 +26,10 @@ export { handleTakeOver } from '../../utilities/handleTakeOver.js' export { hasSavePermission } from '../../utilities/hasSavePermission.js' export { isClientUserObject } from '../../utilities/isClientUserObject.js' export { isEditing } from '../../utilities/isEditing.js' -export { mergeListSearchAndWhere } from '../../utilities/mergeListSearchAndWhere.js' export { sanitizeID } from '../../utilities/sanitizeID.js' +/** + * @deprecated + * The `mergeListSearchAndWhere` function is deprecated. + * Import this from `payload/shared` instead. + */ +export { mergeListSearchAndWhere } from 'payload/shared' diff --git a/packages/ui/src/icons/Chevron/index.tsx b/packages/ui/src/icons/Chevron/index.tsx index eb102cabf9..93c7b23c6c 100644 --- a/packages/ui/src/icons/Chevron/index.tsx +++ b/packages/ui/src/icons/Chevron/index.tsx @@ -3,11 +3,13 @@ import React from 'react' import './index.scss' export const ChevronIcon: React.FC<{ + readonly ariaLabel?: string readonly className?: string readonly direction?: 'down' | 'left' | 'right' | 'up' readonly size?: 'large' | 'small' -}> = ({ className, direction, size }) => ( +}> = ({ ariaLabel, className, direction, size }) => ( div { width: 2.5px; height: 2.5px; diff --git a/packages/ui/src/icons/Dots/index.tsx b/packages/ui/src/icons/Dots/index.tsx index 9bea6bf69e..620bccb9b7 100644 --- a/packages/ui/src/icons/Dots/index.tsx +++ b/packages/ui/src/icons/Dots/index.tsx @@ -2,13 +2,24 @@ import React from 'react' import './index.scss' -export const Dots: React.FC<{ ariaLabel?: string; className?: string }> = ({ - ariaLabel, - className, -}) => ( +const baseClass = 'dots' + +export const Dots: React.FC<{ + ariaLabel?: string + className?: string + noBackground?: boolean + orientation?: 'horizontal' | 'vertical' +}> = ({ ariaLabel, className, noBackground, orientation = 'vertical' }) => (
diff --git a/packages/ui/src/icons/People/index.scss b/packages/ui/src/icons/People/index.scss new file mode 100644 index 0000000000..f0156ed8c6 --- /dev/null +++ b/packages/ui/src/icons/People/index.scss @@ -0,0 +1,11 @@ +@import '../../scss/styles'; + +@layer payload-default { + .icon--people { + .stroke { + stroke: currentColor; + stroke-width: $style-stroke-width; + fill: none; + } + } +} diff --git a/packages/ui/src/icons/People/index.tsx b/packages/ui/src/icons/People/index.tsx new file mode 100644 index 0000000000..23f5c168c0 --- /dev/null +++ b/packages/ui/src/icons/People/index.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +import './index.scss' + +export const PeopleIcon: React.FC<{ + className: string +}> = ({ className }) => ( + + + +) diff --git a/packages/ui/src/providers/DocumentInfo/types.ts b/packages/ui/src/providers/DocumentInfo/types.ts index d282fdfad4..6babc9b439 100644 --- a/packages/ui/src/providers/DocumentInfo/types.ts +++ b/packages/ui/src/providers/DocumentInfo/types.ts @@ -36,6 +36,7 @@ export type DocumentInfoProps = { readonly isLocked: boolean readonly lastUpdateTime: number readonly mostRecentVersionIsAutosaved: boolean + readonly redirectAfterCreate?: boolean readonly redirectAfterDelete?: boolean readonly redirectAfterDuplicate?: boolean readonly unpublishedVersionCount: number diff --git a/packages/ui/src/providers/EntityVisibility/index.tsx b/packages/ui/src/providers/EntityVisibility/index.tsx index accaac5c43..2da537ebe3 100644 --- a/packages/ui/src/providers/EntityVisibility/index.tsx +++ b/packages/ui/src/providers/EntityVisibility/index.tsx @@ -48,5 +48,4 @@ export const EntityVisibilityProvider: React.FC<{ ) } -export const useEntityVisibility = (): VisibleEntitiesContextType => - use(EntityVisibilityContext) +export const useEntityVisibility = (): VisibleEntitiesContextType => use(EntityVisibilityContext) diff --git a/packages/ui/src/providers/ListQuery/context.ts b/packages/ui/src/providers/ListQuery/context.ts index 99c732889d..9cd564575a 100644 --- a/packages/ui/src/providers/ListQuery/context.ts +++ b/packages/ui/src/providers/ListQuery/context.ts @@ -5,3 +5,7 @@ import type { IListQueryContext } from './types.js' export const ListQueryContext = createContext({} as IListQueryContext) export const useListQuery = (): IListQueryContext => use(ListQueryContext) + +export const ListQueryModifiedContext = createContext(false) + +export const useListQueryModified = (): boolean => use(ListQueryModifiedContext) diff --git a/packages/ui/src/providers/ListQuery/index.tsx b/packages/ui/src/providers/ListQuery/index.tsx index 93d9ddf0eb..ad9dd0669c 100644 --- a/packages/ui/src/providers/ListQuery/index.tsx +++ b/packages/ui/src/providers/ListQuery/index.tsx @@ -3,20 +3,21 @@ import { useRouter, useSearchParams } from 'next/navigation.js' import { type ListQuery, type Where } from 'payload' import { isNumber, transformColumnsToSearchParams } from 'payload/shared' import * as qs from 'qs-esm' -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { ListQueryProps } from './types.js' +import type { IListQueryContext, ListQueryProps } from './types.js' 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 { ListQueryContext } from './context.js' +import { ListQueryContext, ListQueryModifiedContext } from './context.js' export { useListQuery } from './context.js' export const ListQueryProvider: React.FC = ({ children, + collectionSlug, columns, data, defaultLimit, @@ -29,12 +30,17 @@ export const ListQueryProvider: React.FC = ({ const router = useRouter() const rawSearchParams = useSearchParams() const { startRouteTransition } = useRouteTransition() + const [modified, setModified] = useState(false) const searchParams = useMemo( () => parseSearchParams(rawSearchParams), [rawSearchParams], ) + const contextRef = useRef({} as IListQueryContext) + + contextRef.current.modified = modified + const { onQueryChange } = useListDrawerContext() const [currentQuery, setCurrentQuery] = useState(() => { @@ -47,20 +53,33 @@ export const ListQueryProvider: React.FC = ({ const refineListData = useCallback( // eslint-disable-next-line @typescript-eslint/require-await - async (query: ListQuery) => { - let page = 'page' in query ? query.page : currentQuery?.page + async (incomingQuery: ListQuery, modified?: boolean) => { + if (modified !== undefined) { + setModified(modified) + } else { + setModified(true) + } - if ('where' in query || 'search' in query) { + let page = 'page' in incomingQuery ? incomingQuery.page : currentQuery?.page + + if ('where' in incomingQuery || 'search' in incomingQuery) { page = '1' } const newQuery: ListQuery = { - columns: 'columns' in query ? query.columns : currentQuery.columns, - limit: 'limit' in query ? query.limit : (currentQuery?.limit ?? String(defaultLimit)), + columns: 'columns' in incomingQuery ? incomingQuery.columns : currentQuery.columns, + limit: + 'limit' in incomingQuery + ? incomingQuery.limit + : (currentQuery?.limit ?? String(defaultLimit)), page, - search: 'search' in query ? query.search : currentQuery?.search, - sort: 'sort' in query ? query.sort : ((currentQuery?.sort as string) ?? defaultSort), - where: 'where' in query ? query.where : currentQuery?.where, + preset: 'preset' in incomingQuery ? incomingQuery.preset : currentQuery?.preset, + search: 'search' in incomingQuery ? incomingQuery.search : currentQuery?.search, + sort: + 'sort' in incomingQuery + ? incomingQuery.sort + : ((currentQuery?.sort as string) ?? defaultSort), + where: 'where' in incomingQuery ? incomingQuery.where : currentQuery?.where, } if (modifySearchParams) { @@ -89,6 +108,7 @@ export const ListQueryProvider: React.FC = ({ currentQuery?.search, currentQuery?.sort, currentQuery?.where, + currentQuery?.preset, startRouteTransition, defaultLimit, defaultSort, @@ -180,6 +200,7 @@ export const ListQueryProvider: React.FC = ({ return ( = ({ handleWhereChange, query: currentQuery, refineListData, + setModified, + ...contextRef.current, }} > - {children} + {children} ) } diff --git a/packages/ui/src/providers/ListQuery/types.ts b/packages/ui/src/providers/ListQuery/types.ts index accd66a906..979b960a1f 100644 --- a/packages/ui/src/providers/ListQuery/types.ts +++ b/packages/ui/src/providers/ListQuery/types.ts @@ -1,8 +1,10 @@ import type { + ClientCollectionConfig, ColumnPreference, ListPreferences, ListQuery, PaginatedDocs, + QueryPreset, Sort, Where, } from 'payload' @@ -19,7 +21,7 @@ export type OnListQueryChange = (query: ListQuery) => void export type ListQueryProps = { readonly children: React.ReactNode - readonly collectionSlug?: string + readonly collectionSlug?: ClientCollectionConfig['slug'] readonly columns?: ColumnPreference[] readonly data: PaginatedDocs readonly defaultLimit?: number @@ -34,9 +36,12 @@ export type ListQueryProps = { } export type IListQueryContext = { + collectionSlug: ClientCollectionConfig['slug'] data: PaginatedDocs defaultLimit?: number defaultSort?: Sort + modified: boolean query: ListQuery - refineListData: (args: ListQuery) => Promise + refineListData: (args: ListQuery, setModified?: boolean) => Promise + setModified: (modified: boolean) => void } & ContextHandlers diff --git a/packages/ui/src/providers/Preferences/index.tsx b/packages/ui/src/providers/Preferences/index.tsx index f0fe18960c..f405bfea76 100644 --- a/packages/ui/src/providers/Preferences/index.tsx +++ b/packages/ui/src/providers/Preferences/index.tsx @@ -51,9 +51,11 @@ export const PreferencesProvider: React.FC<{ children?: React.ReactNode }> = ({ const getPreference = useCallback( async (key: string): Promise => { const prefs = preferencesRef.current + if (typeof prefs[key] !== 'undefined') { return prefs[key] } + const promise = new Promise((resolve: (value: T) => void) => { void (async () => { const request = await requests.get(`${serverURL}${api}/payload-preferences/${key}`, { @@ -61,16 +63,22 @@ export const PreferencesProvider: React.FC<{ children?: React.ReactNode }> = ({ 'Accept-Language': i18n.language, }, }) + let value = null + if (request.status === 200) { const preference = await request.json() value = preference.value } + preferencesRef.current[key] = value + resolve(value) })() }) + prefs[key] = promise + return promise }, [i18n.language, api, preferencesRef, serverURL], diff --git a/packages/ui/src/providers/ServerFunctions/index.tsx b/packages/ui/src/providers/ServerFunctions/index.tsx index e0061a7e2d..995b08087b 100644 --- a/packages/ui/src/providers/ServerFunctions/index.tsx +++ b/packages/ui/src/providers/ServerFunctions/index.tsx @@ -44,6 +44,7 @@ type RenderDocument = (args: { initialData?: Data locale?: Locale overrideEntityVisibility?: boolean + redirectAfterCreate?: boolean redirectAfterDelete?: boolean redirectAfterDuplicate?: boolean signal?: AbortSignal diff --git a/packages/ui/src/elements/TableColumns/RenderDefaultCell/index.scss b/packages/ui/src/providers/TableColumns/RenderDefaultCell/index.scss similarity index 100% rename from packages/ui/src/elements/TableColumns/RenderDefaultCell/index.scss rename to packages/ui/src/providers/TableColumns/RenderDefaultCell/index.scss diff --git a/packages/ui/src/elements/TableColumns/RenderDefaultCell/index.tsx b/packages/ui/src/providers/TableColumns/RenderDefaultCell/index.tsx similarity index 93% rename from packages/ui/src/elements/TableColumns/RenderDefaultCell/index.tsx rename to packages/ui/src/providers/TableColumns/RenderDefaultCell/index.tsx index 4fefe9bb96..e962df1c72 100644 --- a/packages/ui/src/elements/TableColumns/RenderDefaultCell/index.tsx +++ b/packages/ui/src/providers/TableColumns/RenderDefaultCell/index.tsx @@ -4,16 +4,15 @@ import type { DefaultCellComponentProps } from 'payload' import React from 'react' import { useListDrawerContext } from '../../../elements/ListDrawer/Provider.js' -import { DefaultCell } from '../../Table/DefaultCell/index.js' -import './index.scss' +import { DefaultCell } from '../../../elements/Table/DefaultCell/index.js' import { useTableColumns } from '../index.js' +import './index.scss' const baseClass = 'default-cell' const CellPropsContext = React.createContext(null) -export const useCellProps = (): DefaultCellComponentProps | null => - React.use(CellPropsContext) +export const useCellProps = (): DefaultCellComponentProps | null => React.use(CellPropsContext) export const RenderDefaultCell: React.FC<{ clientProps: DefaultCellComponentProps diff --git a/packages/ui/src/elements/TableColumns/buildColumnState.tsx b/packages/ui/src/providers/TableColumns/buildColumnState.tsx similarity index 98% rename from packages/ui/src/elements/TableColumns/buildColumnState.tsx rename to packages/ui/src/providers/TableColumns/buildColumnState.tsx index bdd89ab7b5..de12112783 100644 --- a/packages/ui/src/elements/TableColumns/buildColumnState.tsx +++ b/packages/ui/src/providers/TableColumns/buildColumnState.tsx @@ -24,8 +24,9 @@ import { } from 'payload/shared' import React from 'react' -import type { SortColumnProps } from '../SortColumn/index.js' +import type { SortColumnProps } from '../../elements/SortColumn/index.js' +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' import { DefaultCell, RenderCustomComponent, @@ -34,7 +35,6 @@ import { // eslint-disable-next-line payload/no-imports-from-exports-dir } from '../../exports/client/index.js' import { hasOptionLabelJSXElement } from '../../utilities/hasOptionLabelJSXElement.js' -import { RenderServerComponent } from '../RenderServerComponent/index.js' import { filterFields } from './filterFields.js' type Args = { diff --git a/packages/ui/src/elements/TableColumns/buildPolymorphicColumnState.tsx b/packages/ui/src/providers/TableColumns/buildPolymorphicColumnState.tsx similarity index 98% rename from packages/ui/src/elements/TableColumns/buildPolymorphicColumnState.tsx rename to packages/ui/src/providers/TableColumns/buildPolymorphicColumnState.tsx index 916ce7836f..b4273394cb 100644 --- a/packages/ui/src/elements/TableColumns/buildPolymorphicColumnState.tsx +++ b/packages/ui/src/providers/TableColumns/buildPolymorphicColumnState.tsx @@ -23,7 +23,7 @@ import { } from 'payload/shared' import React from 'react' -import type { SortColumnProps } from '../SortColumn/index.js' +import type { SortColumnProps } from '../../elements/SortColumn/index.js' import { RenderCustomComponent, @@ -31,7 +31,7 @@ import { SortColumn, // eslint-disable-next-line payload/no-imports-from-exports-dir } from '../../exports/client/index.js' -import { RenderServerComponent } from '../RenderServerComponent/index.js' +import { RenderServerComponent } from '../../elements/RenderServerComponent/index.js' import { filterFields } from './filterFields.js' type Args = { diff --git a/packages/ui/src/elements/TableColumns/context.ts b/packages/ui/src/providers/TableColumns/context.ts similarity index 100% rename from packages/ui/src/elements/TableColumns/context.ts rename to packages/ui/src/providers/TableColumns/context.ts diff --git a/packages/ui/src/elements/TableColumns/filterFields.tsx b/packages/ui/src/providers/TableColumns/filterFields.tsx similarity index 100% rename from packages/ui/src/elements/TableColumns/filterFields.tsx rename to packages/ui/src/providers/TableColumns/filterFields.tsx diff --git a/packages/ui/src/elements/TableColumns/getInitialColumns.ts b/packages/ui/src/providers/TableColumns/getInitialColumns.ts similarity index 100% rename from packages/ui/src/elements/TableColumns/getInitialColumns.ts rename to packages/ui/src/providers/TableColumns/getInitialColumns.ts diff --git a/packages/ui/src/elements/TableColumns/index.tsx b/packages/ui/src/providers/TableColumns/index.tsx similarity index 92% rename from packages/ui/src/elements/TableColumns/index.tsx rename to packages/ui/src/providers/TableColumns/index.tsx index 52c9cec4ea..3bb873e730 100644 --- a/packages/ui/src/elements/TableColumns/index.tsx +++ b/packages/ui/src/providers/TableColumns/index.tsx @@ -1,9 +1,9 @@ 'use client' import { type Column } from 'payload' import { transformColumnsToSearchParams } from 'payload/shared' -import React, { startTransition, useCallback } from 'react' +import React, { startTransition, useCallback, useRef } from 'react' -import type { TableColumnsProviderProps } from './types.js' +import type { ITableColumns, TableColumnsProviderProps } from './types.js' import { useConfig } from '../../providers/Config/index.js' import { useListQuery } from '../../providers/ListQuery/index.js' @@ -29,6 +29,8 @@ export const TableColumnsProvider: React.FC = ({ (state, action: Column[]) => action, ) + const contextRef = useRef({} as ITableColumns) + const toggleColumn = useCallback( async (column: string) => { const newColumnState = (columnState || []).map((col) => { @@ -98,6 +100,7 @@ export const TableColumnsProvider: React.FC = ({ resetColumnsState, setActiveColumns, toggleColumn, + ...contextRef.current, }} > {children} diff --git a/packages/ui/src/elements/TableColumns/types.ts b/packages/ui/src/providers/TableColumns/types.ts similarity index 93% rename from packages/ui/src/elements/TableColumns/types.ts rename to packages/ui/src/providers/TableColumns/types.ts index 3cd66cca57..1b593f4952 100644 --- a/packages/ui/src/elements/TableColumns/types.ts +++ b/packages/ui/src/providers/TableColumns/types.ts @@ -1,6 +1,6 @@ import type { Column, ListPreferences } from 'payload' -import type { SortColumnProps } from '../SortColumn/index.js' +import type { SortColumnProps } from '../../elements/SortColumn/index.js' export interface ITableColumns { columns: Column[] diff --git a/packages/ui/src/utilities/renderTable.tsx b/packages/ui/src/utilities/renderTable.tsx index 9ef3a6d154..1699672972 100644 --- a/packages/ui/src/utilities/renderTable.tsx +++ b/packages/ui/src/utilities/renderTable.tsx @@ -18,10 +18,10 @@ import { fieldAffectsData, fieldIsHiddenOrDisabled, flattenTopLevelFields } from import type { Column } from '../exports/client/index.js' import { RenderServerComponent } from '../elements/RenderServerComponent/index.js' -import { buildColumnState } from '../elements/TableColumns/buildColumnState.js' -import { buildPolymorphicColumnState } from '../elements/TableColumns/buildPolymorphicColumnState.js' -import { filterFields } from '../elements/TableColumns/filterFields.js' -import { getInitialColumns } from '../elements/TableColumns/getInitialColumns.js' +import { buildColumnState } from '../providers/TableColumns/buildColumnState.js' +import { buildPolymorphicColumnState } from '../providers/TableColumns/buildPolymorphicColumnState.js' +import { filterFields } from '../providers/TableColumns/filterFields.js' +import { getInitialColumns } from '../providers/TableColumns/getInitialColumns.js' // eslint-disable-next-line payload/no-imports-from-exports-dir import { Pill, SelectAll, SelectRow, Table } from '../exports/client/index.js' diff --git a/packages/ui/src/utilities/upsertPreferences.ts b/packages/ui/src/utilities/upsertPreferences.ts index 375d255660..26e51ac814 100644 --- a/packages/ui/src/utilities/upsertPreferences.ts +++ b/packages/ui/src/utilities/upsertPreferences.ts @@ -53,14 +53,23 @@ export const getPreferences = cache( * @param value - The new value to merge with the existing preferences */ export const upsertPreferences = async | string>({ + customMerge, key, req, value: incomingValue, }: { key: string req: PayloadRequest - value: T -}): Promise => { +} & ( + | { + customMerge: (existingValue: T) => T + value?: never + } + | { + customMerge?: never + value: T + } +)): Promise => { const existingPrefs: { id?: DefaultDocumentIDType; value?: T } = req.user ? await getPreferences(key, req.payload, req.user.id, req.user.collection) : {} @@ -83,14 +92,20 @@ export const upsertPreferences = async | stri user: req.user, }) } else { - // Strings are valid JSON, i.e. `locale` saved as a string to the locale preferences - const mergedPrefs = - typeof incomingValue === 'object' - ? { - ...(typeof existingPrefs.value === 'object' ? existingPrefs?.value : {}), // Shallow merge existing prefs to acquire any missing keys from incoming value - ...removeUndefined(incomingValue || {}), - } - : incomingValue + let mergedPrefs: T + + if (typeof customMerge === 'function') { + mergedPrefs = customMerge(existingPrefs.value) + } else { + // Strings are valid JSON, i.e. `locale` saved as a string to the locale preferences + mergedPrefs = + typeof incomingValue === 'object' + ? ({ + ...(typeof existingPrefs.value === 'object' ? existingPrefs?.value : {}), // Shallow merge existing prefs to acquire any missing keys from incoming value + ...removeUndefined(incomingValue || {}), + } as T) + : incomingValue + } if (!dequal(mergedPrefs, existingPrefs.value)) { newPrefs = await req.payload diff --git a/packages/ui/src/views/Edit/index.tsx b/packages/ui/src/views/Edit/index.tsx index 9dd7938655..78a054cb39 100644 --- a/packages/ui/src/views/Edit/index.tsx +++ b/packages/ui/src/views/Edit/index.tsx @@ -73,6 +73,7 @@ export function DefaultEditView({ isEditing, isInitializing, lastUpdateTime, + redirectAfterCreate, redirectAfterDelete, redirectAfterDuplicate, savedDocumentData, @@ -254,7 +255,7 @@ export function DefaultEditView({ }) } - if (!isEditing && depth < 2) { + if (!isEditing && depth < 2 && redirectAfterCreate !== false) { // Redirect to the same locale if it's been set const redirectRoute = formatAdminURL({ adminRoute, diff --git a/packages/ui/src/views/List/index.tsx b/packages/ui/src/views/List/index.tsx index 405a123481..cd5b2c2a51 100644 --- a/packages/ui/src/views/List/index.tsx +++ b/packages/ui/src/views/List/index.tsx @@ -20,12 +20,12 @@ import { RenderCustomComponent } from '../../elements/RenderCustomComponent/inde import { SelectMany } from '../../elements/SelectMany/index.js' import { useStepNav } from '../../elements/StepNav/index.js' import { RelationshipProvider } from '../../elements/Table/RelationshipProvider/index.js' -import { TableColumnsProvider } from '../../elements/TableColumns/index.js' import { ViewDescription } from '../../elements/ViewDescription/index.js' import { useAuth } from '../../providers/Auth/index.js' import { useConfig } from '../../providers/Config/index.js' import { useListQuery } from '../../providers/ListQuery/index.js' 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 { ListHeader } from './ListHeader/index.js' @@ -45,10 +45,13 @@ export function DefaultListView(props: ListViewClientProps) { Description, disableBulkDelete, disableBulkEdit, + disableQueryPresets, enableRowSelections, hasCreatePermission: hasCreatePermissionFromProps, listMenuItems, newDocumentURL, + queryPreset, + queryPresetPermissions, renderedFilters, resolvedFilterOptions, Table: InitialTable, @@ -189,7 +192,12 @@ export function DefaultListView(props: ListViewClientProps) { } collectionConfig={collectionConfig} collectionSlug={collectionSlug} + disableQueryPresets={ + collectionConfig?.enableQueryPresets !== true || disableQueryPresets + } listMenuItems={listMenuItems} + queryPreset={queryPreset} + queryPresetPermissions={queryPresetPermissions} renderedFilters={renderedFilters} resolvedFilterOptions={resolvedFilterOptions} /> diff --git a/test/admin/e2e/list-view/e2e.spec.ts b/test/admin/e2e/list-view/e2e.spec.ts index 4aeddcb2cd..796a21b3c8 100644 --- a/test/admin/e2e/list-view/e2e.spec.ts +++ b/test/admin/e2e/list-view/e2e.spec.ts @@ -392,7 +392,7 @@ describe('List View', () => { test('should reset filter value when a different field is selected', async () => { const id = (await page.locator('.cell-id').first().innerText()).replace('ID: ', '') - const whereBuilder = await addListFilter({ + const { whereBuilder } = await addListFilter({ page, fieldLabel: 'ID', operatorLabel: 'equals', @@ -416,7 +416,7 @@ describe('List View', () => { test('should remove condition from URL when value is cleared', async () => { await page.goto(postsUrl.list) - const whereBuilder = await addListFilter({ + const { whereBuilder } = await addListFilter({ page, fieldLabel: 'Relationship', operatorLabel: 'equals', @@ -431,16 +431,13 @@ describe('List View', () => { await whereBuilder.locator('.condition__value .clear-indicator').click() await page.waitForURL(new RegExp(encodedQueryString)) - }) - - test.skip('should remove condition from URL when a different field is selected', async () => { - // TODO: fix this bug and write this test + expect(true).toBe(true) }) test('should refresh relationship values when a different field is selected', async () => { await page.goto(postsUrl.list) - const whereBuilder = await addListFilter({ + const { whereBuilder } = await addListFilter({ page, fieldLabel: 'Relationship', operatorLabel: 'equals', @@ -600,7 +597,7 @@ describe('List View', () => { test('should reset filter values for every additional filter', async () => { await page.goto(postsUrl.list) - const whereBuilder = await addListFilter({ + const { whereBuilder } = await addListFilter({ page, fieldLabel: 'Tab 1 > Title', operatorLabel: 'equals', @@ -622,7 +619,7 @@ describe('List View', () => { test('should not re-render page upon typing in a value in the filter value field', async () => { await page.goto(postsUrl.list) - const whereBuilder = await addListFilter({ + const { whereBuilder } = await addListFilter({ page, fieldLabel: 'Tab 1 > Title', operatorLabel: 'equals', @@ -645,7 +642,7 @@ describe('List View', () => { test('should still show second filter if two filters exist and first filter is removed', async () => { await page.goto(postsUrl.list) - const whereBuilder = await addListFilter({ + const { whereBuilder } = await addListFilter({ page, fieldLabel: 'Tab 1 > Title', operatorLabel: 'equals', @@ -739,7 +736,7 @@ describe('List View', () => { test('should properly paginate many documents', async () => { await page.goto(with300DocumentsUrl.list) - const whereBuilder = await addListFilter({ + const { whereBuilder } = await addListFilter({ page, fieldLabel: 'Self Relation', operatorLabel: 'equals', diff --git a/test/fields-relationship/e2e.spec.ts b/test/fields-relationship/e2e.spec.ts index ef3d48d692..245709e55d 100644 --- a/test/fields-relationship/e2e.spec.ts +++ b/test/fields-relationship/e2e.spec.ts @@ -332,7 +332,7 @@ describe('Relationship Field', () => { // now ensure that the same filter options are applied in the list view await page.goto(url.list) - const whereBuilder = await addListFilter({ + const { whereBuilder } = await addListFilter({ page, fieldLabel: 'Relationship Filtered By Field', operatorLabel: 'equals', @@ -367,7 +367,7 @@ describe('Relationship Field', () => { // now ensure that the same filter options are applied in the list view await page.goto(url.list) - const whereBuilder = await addListFilter({ + const { whereBuilder } = await addListFilter({ page, fieldLabel: 'Collapsible > Nested Relationship Filtered By Field', operatorLabel: 'equals', diff --git a/test/fields/collections/Array/e2e.spec.ts b/test/fields/collections/Array/e2e.spec.ts index 72573dc97c..d65b67f986 100644 --- a/test/fields/collections/Array/e2e.spec.ts +++ b/test/fields/collections/Array/e2e.spec.ts @@ -114,7 +114,6 @@ describe('Array', () => { await expect(page.locator('#field-customArrayField__0__text')).toBeVisible() }) - test('should bypass min rows validation when no rows present and field is not required', async () => { await page.goto(url.create) await saveDocAndAssert(page) diff --git a/test/fields/collections/Text/e2e.spec.ts b/test/fields/collections/Text/e2e.spec.ts index 935c80cd6d..c5261ae944 100644 --- a/test/fields/collections/Text/e2e.spec.ts +++ b/test/fields/collections/Text/e2e.spec.ts @@ -79,7 +79,7 @@ describe('Text', () => { await expect(page.locator('.cell-hiddenTextField')).toBeHidden() await expect(page.locator('#heading-hiddenTextField')).toBeHidden() - const columnContainer = await openListColumns(page, {}) + const { columnContainer } = await openListColumns(page, {}) await expect( columnContainer.locator('.column-selector__column', { @@ -105,7 +105,7 @@ describe('Text', () => { await expect(page.locator('.cell-disabledTextField')).toBeHidden() await expect(page.locator('#heading-disabledTextField')).toBeHidden() - const columnContainer = await openListColumns(page, {}) + const { columnContainer } = await openListColumns(page, {}) await expect( columnContainer.locator('.column-selector__column', { @@ -133,7 +133,7 @@ describe('Text', () => { await expect(page.locator('.cell-adminHiddenTextField').first()).toBeVisible() await expect(page.locator('#heading-adminHiddenTextField')).toBeVisible() - const columnContainer = await openListColumns(page, {}) + const { columnContainer } = await openListColumns(page, {}) await expect( columnContainer.locator('.column-selector__column', { diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 8a605cf6c0..b6babe2b1c 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -54,6 +54,7 @@ export type SupportedTimezones = | 'Asia/Singapore' | 'Asia/Tokyo' | 'Asia/Seoul' + | 'Australia/Brisbane' | 'Australia/Sydney' | 'Pacific/Guam' | 'Pacific/Noumea' diff --git a/test/helpers.ts b/test/helpers.ts index 6bb030183b..f5e830de46 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -245,7 +245,7 @@ export async function saveDocAndAssert( if (expectation === 'success') { await expect(page.locator('.payload-toast-container')).toContainText('successfully') - await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create') + await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('/create') } else { await expect(page.locator('.payload-toast-container .toast-error')).toBeVisible() } diff --git a/test/helpers/e2e/addListFilter.ts b/test/helpers/e2e/addListFilter.ts index 3cc9dcd83f..d8f6e9e6f3 100644 --- a/test/helpers/e2e/addListFilter.ts +++ b/test/helpers/e2e/addListFilter.ts @@ -18,7 +18,9 @@ export const addListFilter = async ({ replaceExisting?: boolean skipValueInput?: boolean value?: string -}): Promise => { +}): Promise<{ + whereBuilder: Locator +}> => { await openListFilters(page, {}) const whereBuilder = page.locator('.where-builder') @@ -53,5 +55,5 @@ export const addListFilter = async ({ } } - return whereBuilder + return { whereBuilder } } diff --git a/test/helpers/e2e/openListColumns.ts b/test/helpers/e2e/openListColumns.ts index a9664e58cd..2377920756 100644 --- a/test/helpers/e2e/openListColumns.ts +++ b/test/helpers/e2e/openListColumns.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' @@ -11,7 +11,9 @@ export const openListColumns = async ( columnContainerSelector?: string togglerSelector?: string }, -): Promise => { +): Promise<{ + columnContainer: Locator +}> => { const columnContainer = page.locator(columnContainerSelector).first() const isAlreadyOpen = await columnContainer.isVisible() @@ -22,5 +24,5 @@ export const openListColumns = async ( await expect(page.locator(`${columnContainerSelector}.rah-static--height-auto`)).toBeVisible() - return columnContainer + return { columnContainer } } diff --git a/test/helpers/e2e/openListFilters.ts b/test/helpers/e2e/openListFilters.ts index 3533195181..5598e6683c 100644 --- a/test/helpers/e2e/openListFilters.ts +++ b/test/helpers/e2e/openListFilters.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' @@ -11,10 +11,12 @@ export const openListFilters = async ( filterContainerSelector?: string togglerSelector?: string }, -) => { - const columnContainer = page.locator(filterContainerSelector).first() +): Promise<{ + filterContainer: Locator +}> => { + const filterContainer = page.locator(filterContainerSelector).first() - const isAlreadyOpen = await columnContainer.isVisible() + const isAlreadyOpen = await filterContainer.isVisible() if (!isAlreadyOpen) { await page.locator(togglerSelector).first().click() @@ -22,5 +24,5 @@ export const openListFilters = async ( await expect(page.locator(`${filterContainerSelector}.rah-static--height-auto`)).toBeVisible() - return columnContainer + return { filterContainer } } diff --git a/test/helpers/e2e/toggleColumn.ts b/test/helpers/e2e/toggleColumn.ts index 442ddea1d9..eb57bf2421 100644 --- a/test/helpers/e2e/toggleColumn.ts +++ b/test/helpers/e2e/toggleColumn.ts @@ -1,4 +1,4 @@ -import type { Page } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' import { expect } from '@playwright/test' @@ -22,8 +22,13 @@ export const toggleColumn = async ( targetState?: 'off' | 'on' togglerSelector?: string }, -): Promise => { - const columnContainer = await openListColumns(page, { togglerSelector, columnContainerSelector }) +): Promise<{ + columnContainer: Locator +}> => { + const { columnContainer } = await openListColumns(page, { + togglerSelector, + columnContainerSelector, + }) const column = columnContainer.locator(`.column-selector .column-selector__column`, { hasText: exactText(columnLabel), @@ -57,7 +62,7 @@ export const toggleColumn = async ( await waitForColumnInURL({ page, columnName, state: targetState }) } - return column + return { columnContainer } } export const waitForColumnInURL = async ({ diff --git a/test/helpers/e2e/toggleListMenu.ts b/test/helpers/e2e/toggleListMenu.ts new file mode 100644 index 0000000000..a91d961b81 --- /dev/null +++ b/test/helpers/e2e/toggleListMenu.ts @@ -0,0 +1,26 @@ +import type { Page } from '@playwright/test' + +import { expect } from '@playwright/test' +import { exactText } from 'helpers.js' + +export async function openListMenu({ page }: { page: Page }) { + const listMenu = page.locator('#list-menu') + await listMenu.locator('button.popup-button').click() + await expect(listMenu.locator('.popup__content')).toBeVisible() +} + +export async function clickListMenuItem({ + page, + menuItemLabel, +}: { + menuItemLabel: string + page: Page +}) { + await openListMenu({ page }) + + const menuItem = page.locator('.popup__content').locator('button', { + hasText: exactText(menuItemLabel), + }) + + await menuItem.click() +} diff --git a/test/helpers/sdk/types.ts b/test/helpers/sdk/types.ts index 79bf3500cc..0a6de84d0d 100644 --- a/test/helpers/sdk/types.ts +++ b/test/helpers/sdk/types.ts @@ -75,7 +75,7 @@ export type UpdateManyArgs< TSlug extends keyof TGeneratedTypes['collections'], > = { id: never - where?: WhereField + where?: Where } & UpdateBaseArgs export type UpdateBaseArgs< diff --git a/test/locked-documents/collections/Pages/index.ts b/test/locked-documents/collections/Pages/index.ts index 96416b26a6..fa0301ed36 100644 --- a/test/locked-documents/collections/Pages/index.ts +++ b/test/locked-documents/collections/Pages/index.ts @@ -1,6 +1,6 @@ import type { CollectionConfig } from 'payload' -export const pagesSlug = 'pages' +import { pagesSlug } from '../../slugs.js' export const PagesCollection: CollectionConfig = { slug: pagesSlug, diff --git a/test/locked-documents/collections/Posts/index.ts b/test/locked-documents/collections/Posts/index.ts index 02eb7a89f1..18f2c757a6 100644 --- a/test/locked-documents/collections/Posts/index.ts +++ b/test/locked-documents/collections/Posts/index.ts @@ -1,6 +1,6 @@ import type { CollectionConfig } from 'payload' -export const postsSlug = 'posts' +import { postsSlug } from '../../slugs.js' export const PostsCollection: CollectionConfig = { slug: postsSlug, diff --git a/test/locked-documents/collections/Users/index.ts b/test/locked-documents/collections/Users/index.ts index 7fe7f5e6c9..136bea72e1 100644 --- a/test/locked-documents/collections/Users/index.ts +++ b/test/locked-documents/collections/Users/index.ts @@ -1,7 +1,9 @@ import type { CollectionConfig } from 'payload' +import { usersSlug } from '../../slugs.js' + export const Users: CollectionConfig = { - slug: 'users', + slug: usersSlug, admin: { useAsTitle: 'name', }, diff --git a/test/locked-documents/config.ts b/test/locked-documents/config.ts index 0fe077fb52..1967cb3e17 100644 --- a/test/locked-documents/config.ts +++ b/test/locked-documents/config.ts @@ -2,13 +2,13 @@ import { fileURLToPath } from 'node:url' import path from 'path' import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' -import { devUser, regularUser } from '../credentials.js' -import { PagesCollection, pagesSlug } from './collections/Pages/index.js' -import { PostsCollection, postsSlug } from './collections/Posts/index.js' +import { PagesCollection } from './collections/Pages/index.js' +import { PostsCollection } from './collections/Posts/index.js' import { TestsCollection } from './collections/Tests/index.js' import { Users } from './collections/Users/index.js' import { AdminGlobal } from './globals/Admin/index.js' import { MenuGlobal } from './globals/Menu/index.js' +import { seed } from './seed.js' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -23,39 +23,7 @@ export default buildConfigWithDefaults({ globals: [AdminGlobal, MenuGlobal], onInit: async (payload) => { if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') { - await payload.create({ - collection: 'users', - data: { - email: devUser.email, - password: devUser.password, - name: 'Admin', - roles: ['is_admin', 'is_user'], - }, - }) - - await payload.create({ - collection: 'users', - data: { - email: regularUser.email, - password: regularUser.password, - name: 'Dev', - roles: ['is_user'], - }, - }) - - await payload.create({ - collection: pagesSlug, - data: { - text: 'example page', - }, - }) - - await payload.create({ - collection: postsSlug, - data: { - text: 'example post', - }, - }) + await seed(payload) } }, typescript: { diff --git a/test/locked-documents/int.spec.ts b/test/locked-documents/int.spec.ts index 5c0bc9c4bf..560569104b 100644 --- a/test/locked-documents/int.spec.ts +++ b/test/locked-documents/int.spec.ts @@ -5,35 +5,29 @@ import { Locked, NotFound } from 'payload' import { wait } from 'payload/shared' import { fileURLToPath } from 'url' -import type { NextRESTClient } from '../helpers/NextRESTClient.js' -import type { Menu, Page, Post, User } from './payload-types.js' +import type { Post, User } from './payload-types.js' import { devUser } from '../credentials.js' import { initPayloadInt } from '../helpers/initPayloadInt.js' -import { pagesSlug } from './collections/Pages/index.js' -import { postsSlug } from './collections/Posts/index.js' import { menuSlug } from './globals/Menu/index.js' +import { pagesSlug, postsSlug } from './slugs.js' const lockedDocumentCollection = 'payload-locked-documents' let payload: Payload -let token: string -let restClient: NextRESTClient const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) describe('Locked documents', () => { let post: Post - let page: Page - let menu: Menu let user: any let user2: any let postConfig: SanitizedCollectionConfig beforeAll(async () => { // @ts-expect-error: initPayloadInt does not have a proper type definition - ;({ payload, restClient } = await initPayloadInt(dirname)) + ;({ payload } = await initPayloadInt(dirname)) postConfig = payload.config.collections.find( ({ slug }) => slug === postsSlug, @@ -49,8 +43,6 @@ describe('Locked documents', () => { user = loginResult.user - token = loginResult.token as string - user2 = await payload.create({ collection: 'users', data: { @@ -66,14 +58,14 @@ describe('Locked documents', () => { }, }) - page = await payload.create({ + await payload.create({ collection: pagesSlug, data: { text: 'some page', }, }) - menu = await payload.updateGlobal({ + await payload.updateGlobal({ slug: menuSlug, data: { globalText: 'global text', diff --git a/test/locked-documents/seed.ts b/test/locked-documents/seed.ts new file mode 100644 index 0000000000..0a7f578f59 --- /dev/null +++ b/test/locked-documents/seed.ts @@ -0,0 +1,57 @@ +import type { Payload } from 'payload' + +import { devUser, regularUser } from '../credentials.js' +import { executePromises } from '../helpers/executePromises.js' +import { seedDB } from '../helpers/seed.js' +import { collectionSlugs, pagesSlug, postsSlug } from './slugs.js' + +export const seed = async (_payload: Payload) => { + await executePromises( + [ + () => + _payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + name: 'Admin', + roles: ['is_admin', 'is_user'], + }, + }), + () => + _payload.create({ + collection: 'users', + data: { + email: regularUser.email, + password: regularUser.password, + name: 'Dev', + roles: ['is_user'], + }, + }), + () => + _payload.create({ + collection: pagesSlug, + data: { + text: 'example page', + }, + }), + () => + _payload.create({ + collection: postsSlug, + data: { + text: 'example post', + }, + }), + ], + false, + ) +} + +export async function clearAndSeedEverything(_payload: Payload) { + return await seedDB({ + _payload, + collectionSlugs, + seedFunction: seed, + snapshotKey: 'adminTests', + }) +} diff --git a/test/locked-documents/slugs.ts b/test/locked-documents/slugs.ts new file mode 100644 index 0000000000..227969b836 --- /dev/null +++ b/test/locked-documents/slugs.ts @@ -0,0 +1,7 @@ +export const pagesSlug = 'pages' + +export const postsSlug = 'posts' + +export const usersSlug = 'users' + +export const collectionSlugs = [pagesSlug, postsSlug, usersSlug] diff --git a/test/query-presets/.gitignore b/test/query-presets/.gitignore new file mode 100644 index 0000000000..cce01755f4 --- /dev/null +++ b/test/query-presets/.gitignore @@ -0,0 +1,2 @@ +/media +/media-gif diff --git a/test/query-presets/collections/Pages/index.ts b/test/query-presets/collections/Pages/index.ts new file mode 100644 index 0000000000..7bd8a3659a --- /dev/null +++ b/test/query-presets/collections/Pages/index.ts @@ -0,0 +1,21 @@ +import type { CollectionConfig } from 'payload' + +import { pagesSlug } from '../../slugs.js' + +export const Pages: CollectionConfig = { + slug: pagesSlug, + admin: { + useAsTitle: 'text', + }, + enableQueryPresets: true, + lockDocuments: false, + fields: [ + { + name: 'text', + type: 'text', + }, + ], + versions: { + drafts: true, + }, +} diff --git a/test/query-presets/collections/Users/index.ts b/test/query-presets/collections/Users/index.ts new file mode 100644 index 0000000000..2d13acb813 --- /dev/null +++ b/test/query-presets/collections/Users/index.ts @@ -0,0 +1,19 @@ +import type { CollectionConfig } from 'payload' + +import { roles } from '../../fields/roles.js' +import { usersSlug } from '../../slugs.js' + +export const Users: CollectionConfig = { + slug: usersSlug, + admin: { + useAsTitle: 'name', + }, + auth: true, + fields: [ + { + name: 'name', + type: 'text', + }, + roles, + ], +} diff --git a/test/query-presets/config.ts b/test/query-presets/config.ts new file mode 100644 index 0000000000..bf0e4d4e05 --- /dev/null +++ b/test/query-presets/config.ts @@ -0,0 +1,66 @@ +import { fileURLToPath } from 'node:url' +import path from 'path' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { Pages } from './collections/Pages/index.js' +import { Users } from './collections/Users/index.js' +import { roles } from './fields/roles.js' +import { seed } from './seed.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + admin: { + importMap: { + baseDir: path.resolve(dirname), + }, + }, + queryPresets: { + // labels: { + // singular: 'Report', + // plural: 'Reports', + // }, + access: { + read: ({ req: { user } }) => + user ? !user?.roles?.some((role) => role === 'anonymous') : false, + update: ({ req: { user } }) => + user ? !user?.roles?.some((role) => role === 'anonymous') : false, + }, + constraints: { + read: [ + { + label: 'Specific Roles', + value: 'specificRoles', + fields: [roles], + access: ({ req: { user } }) => ({ + 'access.read.roles': { + in: user?.roles || [], + }, + }), + }, + ], + update: [ + { + label: 'Specific Roles', + value: 'specificRoles', + fields: [roles], + access: ({ req: { user } }) => ({ + 'access.update.roles': { + in: user?.roles || [], + }, + }), + }, + ], + }, + }, + collections: [Pages, Users], + onInit: async (payload) => { + if (process.env.SEED_IN_CONFIG_ONINIT !== 'false') { + await seed(payload) + } + }, + typescript: { + outputFile: path.resolve(dirname, 'payload-types.ts'), + }, +}) diff --git a/test/query-presets/e2e.spec.ts b/test/query-presets/e2e.spec.ts new file mode 100644 index 0000000000..30c19de3d4 --- /dev/null +++ b/test/query-presets/e2e.spec.ts @@ -0,0 +1,392 @@ +import type { BrowserContext, Page } from '@playwright/test' + +import { expect, test } from '@playwright/test' +import { devUser } from 'credentials.js' +import { openListColumns } from 'helpers/e2e/openListColumns.js' +import { toggleColumn } from 'helpers/e2e/toggleColumn.js' +import * as path from 'path' +import { fileURLToPath } from 'url' + +import type { PayloadTestSDK } from '../helpers/sdk/index.js' +import type { Config } from './payload-types.js' + +import { + ensureCompilationIsDone, + exactText, + initPageConsoleErrorCatch, + saveDocAndAssert, + // throttleTest, +} from '../helpers.js' +import { AdminUrlUtil } from '../helpers/adminUrlUtil.js' +import { clickListMenuItem, openListMenu } from '../helpers/e2e/toggleListMenu.js' +import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js' +import { TEST_TIMEOUT_LONG } from '../playwright.config.js' +import { assertURLParams } from './helpers/assertURLParams.js' +import { openQueryPresetDrawer } from './helpers/openQueryPresetDrawer.js' +import { clearSelectedPreset, selectPreset } from './helpers/togglePreset.js' +import { seedData } from './seed.js' +import { pagesSlug } from './slugs.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +const { beforeAll, describe, beforeEach } = test + +let page: Page +let pagesUrl: AdminUrlUtil +let payload: PayloadTestSDK +let serverURL: string +let everyoneID: string | undefined +let context: BrowserContext +let user: any + +describe('Query Presets', () => { + beforeAll(async ({ browser }, testInfo) => { + testInfo.setTimeout(TEST_TIMEOUT_LONG) + ;({ payload, serverURL } = await initPayloadE2ENoConfig({ dirname })) + + pagesUrl = new AdminUrlUtil(serverURL, pagesSlug) + + context = await browser.newContext() + page = await context.newPage() + + user = await payload + .login({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + ?.then((res) => res.user) // TODO: this type is wrong + + initPageConsoleErrorCatch(page) + + await ensureCompilationIsDone({ page, serverURL }) + }) + + beforeEach(async () => { + // await throttleTest({ + // page, + // context, + // delay: 'Fast 4G', + // }) + + // clear and reseed everything + try { + await payload.delete({ + collection: 'payload-query-presets', + where: { + id: { + exists: true, + }, + }, + }) + + const [, everyone] = await Promise.all([ + payload.delete({ + collection: 'payload-preferences', + where: { + and: [ + { + key: { equals: 'pages-list' }, + }, + { + 'user.relationTo': { + equals: 'users', + }, + }, + { + 'user.value': { + equals: user.id, + }, + }, + ], + }, + }), + payload.create({ + collection: 'payload-query-presets', + data: seedData.everyone, + }), + payload.create({ + collection: 'payload-query-presets', + data: seedData.onlyMe, + }), + payload.create({ + collection: 'payload-query-presets', + data: seedData.specificUsers({ userID: user?.id || '' }), + }), + ]) + + everyoneID = everyone.id + } catch (error) { + console.error('Error in beforeEach:', error) + } + }) + + test('should select preset and apply filters', async () => { + await page.goto(pagesUrl.list) + await selectPreset({ page, presetTitle: seedData.everyone.title }) + + await assertURLParams({ + page, + columns: seedData.everyone.columns, + where: seedData.everyone.where, + presetID: everyoneID, + }) + + expect(true).toBe(true) + }) + + test('should clear selected preset and reset filters', async () => { + await page.goto(pagesUrl.list) + await selectPreset({ page, presetTitle: seedData.everyone.title }) + await clearSelectedPreset({ page }) + expect(true).toBe(true) + }) + + test('should delete a preset, clear selection, and reset changes', async () => { + await page.goto(pagesUrl.list) + await selectPreset({ page, presetTitle: seedData.everyone.title }) + await openListMenu({ page }) + + await clickListMenuItem({ page, menuItemLabel: 'Delete' }) + + await page.locator('#confirm-delete-preset #confirm-action').click() + + const regex = /columns=/ + + await page.waitForURL((url) => !regex.test(url.search), { + timeout: TEST_TIMEOUT_LONG, + }) + + await expect( + page.locator('button#select-preset', { + hasText: exactText('Select Preset'), + }), + ).toBeVisible() + + await openQueryPresetDrawer({ page }) + const modal = page.locator('[id^=list-drawer_0_]') + await expect(modal).toBeVisible() + + await expect( + modal.locator('tbody tr td button', { + hasText: exactText(seedData.everyone.title), + }), + ).toBeHidden() + }) + + test('should save last used preset to preferences and load on initial render', async () => { + await page.goto(pagesUrl.list) + await selectPreset({ page, presetTitle: seedData.everyone.title }) + + await page.reload() + + await assertURLParams({ + page, + columns: seedData.everyone.columns, + where: seedData.everyone.where, + // presetID: everyoneID, + }) + + expect(true).toBe(true) + }) + + test('should only show "edit" and "delete" controls when there is an active preset', async () => { + await page.goto(pagesUrl.list) + await openListMenu({ page }) + + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Edit'), + }), + ).toBeHidden() + + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Delete'), + }), + ).toBeHidden() + + await selectPreset({ page, presetTitle: seedData.everyone.title }) + + await openListMenu({ page }) + + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Edit'), + }), + ).toBeVisible() + + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Delete'), + }), + ).toBeVisible() + }) + + test('should only show "reset" and "save" controls when there is an active preset and changes have been made', async () => { + await page.goto(pagesUrl.list) + + await openListMenu({ page }) + + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Reset'), + }), + ).toBeHidden() + + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Update for everyone'), + }), + ).toBeHidden() + + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Save'), + }), + ).toBeHidden() + + await selectPreset({ page, presetTitle: seedData.onlyMe.title }) + + await toggleColumn(page, { columnLabel: 'ID' }) + + await openListMenu({ page }) + + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Reset'), + }), + ).toBeVisible() + + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Save'), + }), + ).toBeVisible() + }) + + test('should conditionally render "update for everyone" label based on if preset is shared', async () => { + await page.goto(pagesUrl.list) + + await selectPreset({ page, presetTitle: seedData.onlyMe.title }) + + await toggleColumn(page, { columnLabel: 'ID' }) + + await openListMenu({ page }) + + // When not shared, the label is "Save" + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Save'), + }), + ).toBeVisible() + + await selectPreset({ page, presetTitle: seedData.everyone.title }) + + await toggleColumn(page, { columnLabel: 'ID' }) + + await openListMenu({ page }) + + // When shared, the label is "Update for everyone" + await expect( + page.locator('#list-menu .popup__content .popup-button-list__button', { + hasText: exactText('Update for everyone'), + }), + ).toBeVisible() + }) + + test('should reset active changes', async () => { + await page.goto(pagesUrl.list) + await selectPreset({ page, presetTitle: seedData.everyone.title }) + + const { columnContainer } = await toggleColumn(page, { columnLabel: 'ID' }) + + const column = columnContainer.locator(`.column-selector .column-selector__column`, { + hasText: exactText('ID'), + }) + + await openListMenu({ page }) + await clickListMenuItem({ page, menuItemLabel: 'Reset' }) + + await openListColumns(page, {}) + await expect(column).toHaveClass(/column-selector__column--active/) + }) + + test('should only enter modified state when changes are made to an active preset', async () => { + await page.goto(pagesUrl.list) + await expect(page.locator('.list-controls__modified')).toBeHidden() + await selectPreset({ page, presetTitle: seedData.everyone.title }) + await expect(page.locator('.list-controls__modified')).toBeHidden() + await toggleColumn(page, { columnLabel: 'ID' }) + await expect(page.locator('.list-controls__modified')).toBeVisible() + await openListMenu({ page }) + await clickListMenuItem({ page, menuItemLabel: 'Update for everyone' }) + await expect(page.locator('.list-controls__modified')).toBeHidden() + await toggleColumn(page, { columnLabel: 'ID' }) + await expect(page.locator('.list-controls__modified')).toBeVisible() + await openListMenu({ page }) + await clickListMenuItem({ page, menuItemLabel: 'Reset' }) + await expect(page.locator('.list-controls__modified')).toBeHidden() + }) + + test('can edit a preset through the document drawer', async () => { + const presetTitle = 'New Preset' + + await page.goto(pagesUrl.list) + + await selectPreset({ page, presetTitle: seedData.everyone.title }) + await clickListMenuItem({ page, menuItemLabel: 'Edit' }) + + const drawer = page.locator('[id^=doc-drawer_payload-query-presets_0_]') + const titleValue = drawer.locator('input[name="title"]') + await expect(titleValue).toHaveValue(seedData.everyone.title) + + const newTitle = `${seedData.everyone.title} (Updated)` + await drawer.locator('input[name="title"]').fill(newTitle) + + await saveDocAndAssert(page) + + await drawer.locator('button.doc-drawer__header-close').click() + await expect(drawer).toBeHidden() + + await expect(page.locator('button#select-preset')).toHaveText(newTitle) + }) + + test('should not display query presets when admin.enableQueryPresets is not true', async () => { + // go to users list view and ensure the query presets select is not visible + const usersURL = new AdminUrlUtil(serverURL, 'users') + await page.goto(usersURL.list) + await expect(page.locator('#select-preset')).toBeHidden() + }) + + // eslint-disable-next-line playwright/no-skipped-test, playwright/expect-expect + test.skip('can save a preset', () => { + // select a preset, make a change to the presets, click "save for everyone" or "save", and ensure the changes persist + }) + + test('can create new preset', async () => { + await page.goto(pagesUrl.list) + + const presetTitle = 'New Preset' + + await clickListMenuItem({ page, menuItemLabel: 'Create New' }) + const modal = page.locator('[id^=doc-drawer_payload-query-presets_0_]') + await expect(modal).toBeVisible() + await modal.locator('input[name="title"]').fill(presetTitle) + + const currentURL = page.url() + await saveDocAndAssert(page) + await expect(modal).toBeHidden() + + await page.waitForURL(() => page.url() !== currentURL) + + await expect( + page.locator('button#select-preset', { + hasText: exactText(presetTitle), + }), + ).toBeVisible() + }) +}) diff --git a/test/query-presets/eslint.config.js b/test/query-presets/eslint.config.js new file mode 100644 index 0000000000..d7ebe5c4d3 --- /dev/null +++ b/test/query-presets/eslint.config.js @@ -0,0 +1,23 @@ +import { rootParserOptions } from '../../eslint.config.js' +import { testEslintConfig } from '../eslint.config.js' + +/** @typedef {import('eslint').Linter.Config} Config */ + +/** @type {Config[]} */ +export const index = [ + ...testEslintConfig, + { + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: ['./*.ts', './*.tsx'], + defaultProject: './tsconfig.json', + }, + tsconfigDirName: import.meta.dirname, + ...rootParserOptions, + }, + }, + }, +] + +export default index diff --git a/test/query-presets/fields/roles.ts b/test/query-presets/fields/roles.ts new file mode 100644 index 0000000000..ad5ff3a66c --- /dev/null +++ b/test/query-presets/fields/roles.ts @@ -0,0 +1,21 @@ +import type { Field } from 'payload' + +export const roles: Field = { + name: 'roles', + type: 'select', + hasMany: true, + options: [ + { + label: 'Admin', + value: 'admin', + }, + { + label: 'User', + value: 'user', + }, + { + label: 'Anonymous', + value: 'anonymous', + }, + ], +} diff --git a/test/query-presets/helpers/assertURLParams.ts b/test/query-presets/helpers/assertURLParams.ts new file mode 100644 index 0000000000..2e9bf1b166 --- /dev/null +++ b/test/query-presets/helpers/assertURLParams.ts @@ -0,0 +1,39 @@ +import type { Page } from '@playwright/test' +import type { ColumnPreference, Where } from 'payload' + +// import { transformColumnsToSearchParams, transformWhereQuery } from 'payload/shared' +// import * as qs from 'qs-esm' + +import { transformColumnsToSearchParams } from 'payload/shared' + +export async function assertURLParams({ + page, + columns, + where, + presetID, +}: { + columns?: ColumnPreference[] + page: Page + presetID?: string | undefined + where: Where +}) { + if (where) { + // TODO: can't get columns to encode correctly + // const whereQuery = qs.stringify(transformWhereQuery(where)) + // const encodedWhere = encodeURIComponent(whereQuery) + } + + if (columns) { + const escapedColumns = encodeURIComponent( + JSON.stringify(transformColumnsToSearchParams(columns)), + ) + + const columnsRegex = new RegExp(`columns=${escapedColumns}`) + await page.waitForURL(columnsRegex) + } + + if (presetID) { + const presetRegex = new RegExp(`preset=${presetID}`) + await page.waitForURL(presetRegex) + } +} diff --git a/test/query-presets/helpers/openQueryPresetDrawer.ts b/test/query-presets/helpers/openQueryPresetDrawer.ts new file mode 100644 index 0000000000..e9c5db1f1a --- /dev/null +++ b/test/query-presets/helpers/openQueryPresetDrawer.ts @@ -0,0 +1,5 @@ +import type { Page } from '@playwright/test' + +export async function openQueryPresetDrawer({ page }: { page: Page }) { + await page.click('button#select-preset') +} diff --git a/test/query-presets/helpers/togglePreset.ts b/test/query-presets/helpers/togglePreset.ts new file mode 100644 index 0000000000..258c2b0702 --- /dev/null +++ b/test/query-presets/helpers/togglePreset.ts @@ -0,0 +1,53 @@ +import type { Page } from '@playwright/test' + +import { expect } from '@playwright/test' +import { exactText } from 'helpers.js' +import { TEST_TIMEOUT_LONG } from 'playwright.config.js' + +import { openQueryPresetDrawer } from './openQueryPresetDrawer.js' + +export async function selectPreset({ page, presetTitle }: { page: Page; presetTitle: string }) { + await openQueryPresetDrawer({ page }) + const modal = page.locator('[id^=list-drawer_0_]') + await expect(modal).toBeVisible() + + const currentURL = page.url() + + await modal + .locator('tbody tr td button', { + hasText: exactText(presetTitle), + }) + .first() + .click() + + await page.waitForURL(() => page.url() !== currentURL) + + await expect( + page.locator('button#select-preset', { + hasText: exactText(presetTitle), + }), + ).toBeVisible() +} + +export async function clearSelectedPreset({ page }: { page: Page }) { + const queryPresetsControl = page.locator('button#select-preset') + const clearButton = queryPresetsControl.locator('#clear-preset') + + if (await clearButton.isVisible()) { + await clearButton.click() + } + + const regex = /columns=/ + + await page.waitForURL((url) => !regex.test(url.search), { + timeout: TEST_TIMEOUT_LONG, + }) + + await expect(queryPresetsControl.locator('#clear-preset')).toBeHidden() + + await expect( + page.locator('button#select-preset', { + hasText: exactText('Select Preset'), + }), + ).toBeVisible() +} diff --git a/test/query-presets/int.spec.ts b/test/query-presets/int.spec.ts new file mode 100644 index 0000000000..3fba8f4f65 --- /dev/null +++ b/test/query-presets/int.spec.ts @@ -0,0 +1,568 @@ +import type { NextRESTClient } from 'helpers/NextRESTClient.js' +import type { Payload, User } from 'payload' + +import path from 'path' +import { fileURLToPath } from 'url' + +import { devUser, regularUser } from '../credentials.js' +import { initPayloadInt } from '../helpers/initPayloadInt.js' + +const queryPresetsCollectionSlug = 'payload-query-presets' + +let payload: Payload +let restClient: NextRESTClient +let user: User +let user2: User +let anonymousUser: User + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +describe('Query Presets', () => { + beforeAll(async () => { + // @ts-expect-error: initPayloadInt does not have a proper type definition + ;({ payload, restClient } = await initPayloadInt(dirname)) + + user = await payload + .login({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }) + ?.then((result) => result.user) + + user2 = await payload + .login({ + collection: 'users', + data: { + email: regularUser.email, + password: regularUser.password, + }, + }) + ?.then((result) => result.user) + + anonymousUser = await payload + .login({ + collection: 'users', + data: { + email: 'anonymous@email.com', + password: regularUser.password, + }, + }) + ?.then((result) => result.user) + }) + + afterAll(async () => { + if (typeof payload.db.destroy === 'function') { + await payload.db.destroy() + } + }) + + describe('default access control', () => { + it('should only allow logged in users to perform actions', async () => { + // create + try { + const result = await payload.create({ + collection: queryPresetsCollectionSlug, + user: undefined, + overrideAccess: false, + data: { + title: 'Only Logged In Users', + relatedCollection: 'pages', + }, + }) + + expect(result).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('You are not allowed to perform this action.') + } + + const { id } = await payload.create({ + collection: queryPresetsCollectionSlug, + data: { + title: 'Only Logged In Users', + relatedCollection: 'pages', + }, + }) + + // read + try { + const result = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user: undefined, + overrideAccess: false, + id, + }) + + expect(result).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('You are not allowed to perform this action.') + } + + // update + try { + const result = await payload.update({ + collection: queryPresetsCollectionSlug, + id, + user: undefined, + overrideAccess: false, + data: { + title: 'Only Logged In Users (Updated)', + }, + }) + + expect(result).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('You are not allowed to perform this action.') + + // make sure the update didn't go through + const preset = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + id, + }) + + expect(preset.title).toBe('Only Logged In Users') + } + + // delete + try { + const result = await payload.delete({ + collection: queryPresetsCollectionSlug, + id: 'some-id', + user: undefined, + overrideAccess: false, + }) + + expect(result).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('You are not allowed to perform this action.') + + // make sure the delete didn't go through + const preset = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + id, + }) + + expect(preset.title).toBe('Only Logged In Users') + } + }) + + it('should respect access when set to "specificUsers"', async () => { + const presetForSpecificUsers = await payload.create({ + collection: queryPresetsCollectionSlug, + user, + data: { + title: 'Specific Users', + where: { + text: { + equals: 'example page', + }, + }, + access: { + read: { + constraint: 'specificUsers', + users: [user.id], + }, + update: { + constraint: 'specificUsers', + users: [user.id], + }, + }, + relatedCollection: 'pages', + }, + }) + + const foundPresetWithUser1 = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user, + overrideAccess: false, + id: presetForSpecificUsers.id, + }) + + expect(foundPresetWithUser1.id).toBe(presetForSpecificUsers.id) + + try { + const foundPresetWithUser2 = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user: user2, + overrideAccess: false, + id: presetForSpecificUsers.id, + }) + + expect(foundPresetWithUser2).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('Not Found') + } + + const presetUpdatedByUser1 = await payload.update({ + collection: queryPresetsCollectionSlug, + id: presetForSpecificUsers.id, + user, + overrideAccess: false, + data: { + title: 'Specific Users (Updated)', + }, + }) + + expect(presetUpdatedByUser1.title).toBe('Specific Users (Updated)') + + try { + const presetUpdatedByUser2 = await payload.update({ + collection: queryPresetsCollectionSlug, + id: presetForSpecificUsers.id, + user: user2, + overrideAccess: false, + data: { + title: 'Specific Users (Updated)', + }, + }) + + expect(presetUpdatedByUser2).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('You are not allowed to perform this action.') + } + }) + + it('should respect access when set to "onlyMe"', async () => { + // create a new doc so that the creating user is the owner + const presetForOnlyMe = await payload.create({ + collection: queryPresetsCollectionSlug, + user, + data: { + title: 'Only Me', + where: { + text: { + equals: 'example page', + }, + }, + access: { + read: { + constraint: 'onlyMe', + }, + update: { + constraint: 'onlyMe', + }, + }, + relatedCollection: 'pages', + }, + }) + + const foundPresetWithUser1 = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user, + overrideAccess: false, + id: presetForOnlyMe.id, + }) + + expect(foundPresetWithUser1.id).toBe(presetForOnlyMe.id) + + try { + const foundPresetWithUser2 = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user: user2, + overrideAccess: false, + id: presetForOnlyMe.id, + }) + + expect(foundPresetWithUser2).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('Not Found') + } + + const presetUpdatedByUser1 = await payload.update({ + collection: queryPresetsCollectionSlug, + id: presetForOnlyMe.id, + user, + overrideAccess: false, + data: { + title: 'Only Me (Updated)', + }, + }) + + expect(presetUpdatedByUser1.title).toBe('Only Me (Updated)') + + try { + const presetUpdatedByUser2 = await payload.update({ + collection: queryPresetsCollectionSlug, + id: presetForOnlyMe.id, + user: user2, + overrideAccess: false, + data: { + title: 'Only Me (Updated)', + }, + }) + + expect(presetUpdatedByUser2).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('You are not allowed to perform this action.') + } + }) + + it('should respect access when set to "everyone"', async () => { + const presetForEveryone = await payload.create({ + collection: queryPresetsCollectionSlug, + user, + data: { + title: 'Everyone', + where: { + text: { + equals: 'example page', + }, + }, + access: { + read: { + constraint: 'everyone', + }, + update: { + constraint: 'everyone', + }, + delete: { + constraint: 'everyone', + }, + }, + relatedCollection: 'pages', + }, + }) + + const foundPresetWithUser1 = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user, + overrideAccess: false, + id: presetForEveryone.id, + }) + + expect(foundPresetWithUser1.id).toBe(presetForEveryone.id) + + const foundPresetWithUser2 = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user: user2, + overrideAccess: false, + id: presetForEveryone.id, + }) + + expect(foundPresetWithUser2.id).toBe(presetForEveryone.id) + + const presetUpdatedByUser1 = await payload.update({ + collection: queryPresetsCollectionSlug, + id: presetForEveryone.id, + user, + overrideAccess: false, + data: { + title: 'Everyone (Update 1)', + }, + }) + + expect(presetUpdatedByUser1.title).toBe('Everyone (Update 1)') + + const presetUpdatedByUser2 = await payload.update({ + collection: queryPresetsCollectionSlug, + id: presetForEveryone.id, + user: user2, + overrideAccess: false, + data: { + title: 'Everyone (Update 2)', + }, + }) + + expect(presetUpdatedByUser2.title).toBe('Everyone (Update 2)') + }) + }) + + describe('user-defined access control', () => { + it('should respect top-level access control overrides', async () => { + const preset = await payload.create({ + collection: queryPresetsCollectionSlug, + user, + data: { + title: 'Top-Level Access Control Override', + relatedCollection: 'pages', + access: { + read: { + constraint: 'everyone', + }, + update: { + constraint: 'everyone', + }, + delete: { + constraint: 'everyone', + }, + }, + }, + }) + + const foundPresetWithUser1 = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user, + overrideAccess: false, + id: preset.id, + }) + + expect(foundPresetWithUser1.id).toBe(preset.id) + + try { + const foundPresetWithAnonymousUser = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user: anonymousUser, + overrideAccess: false, + id: preset.id, + }) + + expect(foundPresetWithAnonymousUser).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('You are not allowed to perform this action.') + } + }) + + it('should respect access when set to "specificRoles"', async () => { + const presetForSpecificRoles = await payload.create({ + collection: queryPresetsCollectionSlug, + user, + data: { + title: 'Specific Roles', + where: { + text: { + equals: 'example page', + }, + }, + access: { + read: { + constraint: 'specificRoles', + roles: ['admin'], + }, + update: { + constraint: 'specificRoles', + roles: ['admin'], + }, + }, + relatedCollection: 'pages', + }, + }) + + const foundPresetWithUser1 = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user, + overrideAccess: false, + id: presetForSpecificRoles.id, + }) + + expect(foundPresetWithUser1.id).toBe(presetForSpecificRoles.id) + + try { + const foundPresetWithUser2 = await payload.findByID({ + collection: queryPresetsCollectionSlug, + depth: 0, + user: user2, + overrideAccess: false, + id: presetForSpecificRoles.id, + }) + + expect(foundPresetWithUser2).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('Not Found') + } + + const presetUpdatedByUser1 = await payload.update({ + collection: queryPresetsCollectionSlug, + id: presetForSpecificRoles.id, + user, + overrideAccess: false, + data: { + title: 'Specific Roles (Updated)', + }, + }) + + expect(presetUpdatedByUser1.title).toBe('Specific Roles (Updated)') + + try { + const presetUpdatedByUser2 = await payload.update({ + collection: queryPresetsCollectionSlug, + id: presetForSpecificRoles.id, + user: user2, + overrideAccess: false, + data: { + title: 'Specific Roles (Updated)', + }, + }) + + expect(presetUpdatedByUser2).toBeFalsy() + } catch (error: unknown) { + expect((error as Error).message).toBe('You are not allowed to perform this action.') + } + }) + }) + + it.skip('should disable query presets when "enabledQueryPresets" is not true on the collection', async () => { + try { + const result = await payload.create({ + collection: 'payload-query-presets', + user, + data: { + title: 'Disabled Query Presets', + relatedCollection: 'pages', + }, + }) + + // TODO: this test always passes because this expect throws an error which is caught and passes the 'catch' block + expect(result).toBeFalsy() + } catch (error) { + expect(error).toBeDefined() + } + }) + + describe('Where object formatting', () => { + it('transforms "where" query objects into the "and" / "or" format', async () => { + const result = await payload.create({ + collection: queryPresetsCollectionSlug, + user, + data: { + title: 'Where Object Formatting', + where: { + text: { + equals: 'example page', + }, + }, + access: { + read: { + constraint: 'everyone', + }, + update: { + constraint: 'everyone', + }, + delete: { + constraint: 'everyone', + }, + }, + relatedCollection: 'pages', + }, + }) + + expect(result.where).toMatchObject({ + or: [ + { + and: [ + { + text: { + equals: 'example page', + }, + }, + ], + }, + ], + }) + }) + }) +}) diff --git a/test/query-presets/payload-types.ts b/test/query-presets/payload-types.ts new file mode 100644 index 0000000000..20fbd0a7b8 --- /dev/null +++ b/test/query-presets/payload-types.ts @@ -0,0 +1,359 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + collections: { + pages: Page; + users: User; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + 'payload-query-presets': PayloadQueryPreset; + }; + collectionsJoins: {}; + collectionsSelect: { + pages: PagesSelect | PagesSelect; + users: UsersSelect | UsersSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + 'payload-query-presets': PayloadQueryPresetsSelect | PayloadQueryPresetsSelect; + }; + db: { + defaultIDType: number; + }; + globals: {}; + globalsSelect: {}; + locale: null; + user: User & { + collection: 'users'; + }; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "pages". + */ +export interface Page { + id: number; + text?: string | null; + updatedAt: string; + createdAt: string; + _status?: ('draft' | 'published') | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: number; + name?: string | null; + roles?: ('admin' | 'user' | 'anonymous')[] | null; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + password?: string | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: number; + document?: + | ({ + relationTo: 'pages'; + value: number | Page; + } | null) + | ({ + relationTo: 'users'; + value: number | User; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: number | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: number; + user: { + relationTo: 'users'; + value: number | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: number; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-query-presets". + */ +export interface PayloadQueryPreset { + id: number; + title: string; + isShared?: boolean | null; + access?: { + read?: { + constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null; + users?: (number | User)[] | null; + roles?: ('admin' | 'user' | 'anonymous')[] | null; + }; + update?: { + constraint?: ('everyone' | 'onlyMe' | 'specificUsers' | 'specificRoles') | null; + users?: (number | User)[] | null; + roles?: ('admin' | 'user' | 'anonymous')[] | null; + }; + delete?: { + constraint?: ('everyone' | 'onlyMe' | 'specificUsers') | null; + users?: (number | User)[] | null; + }; + }; + where?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + columns?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + relatedCollection: 'pages'; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "pages_select". + */ +export interface PagesSelect { + text?: T; + updatedAt?: T; + createdAt?: T; + _status?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + name?: T; + roles?: T; + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + loginAttempts?: T; + lockUntil?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-query-presets_select". + */ +export interface PayloadQueryPresetsSelect { + title?: T; + isShared?: T; + access?: + | T + | { + read?: + | T + | { + constraint?: T; + users?: T; + roles?: T; + }; + update?: + | T + | { + constraint?: T; + users?: T; + roles?: T; + }; + delete?: + | T + | { + constraint?: T; + users?: T; + }; + }; + where?: T; + columns?: T; + relatedCollection?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} \ No newline at end of file diff --git a/test/query-presets/schema.graphql b/test/query-presets/schema.graphql new file mode 100644 index 0000000000..ef6cee9169 --- /dev/null +++ b/test/query-presets/schema.graphql @@ -0,0 +1,2693 @@ +type Query { + Page(id: String!, draft: Boolean): Page + Pages(draft: Boolean, where: Page_where, limit: Int, page: Int, pagination: Boolean, sort: String): Pages + countPages(draft: Boolean, where: Page_where): countPages + docAccessPage(id: String!): pagesDocAccess + versionPage(id: String): PageVersion + versionsPages(where: versionsPage_where, limit: Int, page: Int, pagination: Boolean, sort: String): versionsPages + User(id: String!, draft: Boolean): User + Users(draft: Boolean, where: User_where, limit: Int, page: Int, pagination: Boolean, sort: String): Users + countUsers(draft: Boolean, where: User_where): countUsers + docAccessUser(id: String!): usersDocAccess + meUser: usersMe + initializedUser: Boolean + PayloadLockedDocument(id: String!, draft: Boolean): PayloadLockedDocument + PayloadLockedDocuments(draft: Boolean, where: PayloadLockedDocument_where, limit: Int, page: Int, pagination: Boolean, sort: String): PayloadLockedDocuments + countPayloadLockedDocuments(draft: Boolean, where: PayloadLockedDocument_where): countPayloadLockedDocuments + docAccessPayloadLockedDocument(id: String!): payload_locked_documentsDocAccess + PayloadPreference(id: String!, draft: Boolean): PayloadPreference + PayloadPreferences(draft: Boolean, where: PayloadPreference_where, limit: Int, page: Int, pagination: Boolean, sort: String): PayloadPreferences + countPayloadPreferences(draft: Boolean, where: PayloadPreference_where): countPayloadPreferences + docAccessPayloadPreference(id: String!): payload_preferencesDocAccess + PayloadListFilter(id: String!, draft: Boolean): PayloadListFilter + PayloadListFilters(draft: Boolean, where: PayloadListFilter_where, limit: Int, page: Int, pagination: Boolean, sort: String): PayloadListFilters + countPayloadListFilters(draft: Boolean, where: PayloadListFilter_where): countPayloadListFilters + docAccessPayloadListFilter(id: String!): payload_list_filtersDocAccess + Access: Access +} + +type Page { + id: String! + text: String + updatedAt: DateTime + createdAt: DateTime + _status: Page__status +} + +""" +A date-time string at UTC, such as 2007-12-03T10:15:30Z, compliant with the `date-time` format outlined in section 5.6 of the RFC 3339 profile of the ISO 8601 standard for representation of dates and times using the Gregorian calendar. +""" +scalar DateTime + +enum Page__status { + draft + published +} + +type Pages { + docs: [Page] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input Page_where { + text: Page_text_operator + updatedAt: Page_updatedAt_operator + createdAt: Page_createdAt_operator + _status: Page__status_operator + id: Page_id_operator + AND: [Page_where_and] + OR: [Page_where_or] +} + +input Page_text_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input Page_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input Page_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input Page__status_operator { + equals: Page__status_Input + not_equals: Page__status_Input + in: [Page__status_Input] + not_in: [Page__status_Input] + all: [Page__status_Input] + exists: Boolean +} + +enum Page__status_Input { + draft + published +} + +input Page_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input Page_where_and { + text: Page_text_operator + updatedAt: Page_updatedAt_operator + createdAt: Page_createdAt_operator + _status: Page__status_operator + id: Page_id_operator + AND: [Page_where_and] + OR: [Page_where_or] +} + +input Page_where_or { + text: Page_text_operator + updatedAt: Page_updatedAt_operator + createdAt: Page_createdAt_operator + _status: Page__status_operator + id: Page_id_operator + AND: [Page_where_and] + OR: [Page_where_or] +} + +type countPages { + totalDocs: Int +} + +type pagesDocAccess { + fields: PagesDocAccessFields + create: PagesCreateDocAccess + read: PagesReadDocAccess + update: PagesUpdateDocAccess + delete: PagesDeleteDocAccess + readVersions: PagesReadVersionsDocAccess +} + +type PagesDocAccessFields { + text: PagesDocAccessFields_text + updatedAt: PagesDocAccessFields_updatedAt + createdAt: PagesDocAccessFields_createdAt + _status: PagesDocAccessFields__status +} + +type PagesDocAccessFields_text { + create: PagesDocAccessFields_text_Create + read: PagesDocAccessFields_text_Read + update: PagesDocAccessFields_text_Update + delete: PagesDocAccessFields_text_Delete +} + +type PagesDocAccessFields_text_Create { + permission: Boolean! +} + +type PagesDocAccessFields_text_Read { + permission: Boolean! +} + +type PagesDocAccessFields_text_Update { + permission: Boolean! +} + +type PagesDocAccessFields_text_Delete { + permission: Boolean! +} + +type PagesDocAccessFields_updatedAt { + create: PagesDocAccessFields_updatedAt_Create + read: PagesDocAccessFields_updatedAt_Read + update: PagesDocAccessFields_updatedAt_Update + delete: PagesDocAccessFields_updatedAt_Delete +} + +type PagesDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type PagesDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type PagesDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type PagesDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type PagesDocAccessFields_createdAt { + create: PagesDocAccessFields_createdAt_Create + read: PagesDocAccessFields_createdAt_Read + update: PagesDocAccessFields_createdAt_Update + delete: PagesDocAccessFields_createdAt_Delete +} + +type PagesDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type PagesDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type PagesDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type PagesDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type PagesDocAccessFields__status { + create: PagesDocAccessFields__status_Create + read: PagesDocAccessFields__status_Read + update: PagesDocAccessFields__status_Update + delete: PagesDocAccessFields__status_Delete +} + +type PagesDocAccessFields__status_Create { + permission: Boolean! +} + +type PagesDocAccessFields__status_Read { + permission: Boolean! +} + +type PagesDocAccessFields__status_Update { + permission: Boolean! +} + +type PagesDocAccessFields__status_Delete { + permission: Boolean! +} + +type PagesCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +""" +The `JSONObject` scalar type represents JSON objects as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSONObject @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +type PagesReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type PagesUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PagesDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type PagesReadVersionsDocAccess { + permission: Boolean! + where: JSONObject +} + +type PageVersion { + parent(draft: Boolean): Page + version: PageVersion_Version + createdAt: DateTime + updatedAt: DateTime + latest: Boolean + id: String +} + +type PageVersion_Version { + text: String + updatedAt: DateTime + createdAt: DateTime + _status: PageVersion_Version__status +} + +enum PageVersion_Version__status { + draft + published +} + +type versionsPages { + docs: [PageVersion] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input versionsPage_where { + parent: versionsPage_parent_operator + version__text: versionsPage_version__text_operator + version__updatedAt: versionsPage_version__updatedAt_operator + version__createdAt: versionsPage_version__createdAt_operator + version___status: versionsPage_version___status_operator + createdAt: versionsPage_createdAt_operator + updatedAt: versionsPage_updatedAt_operator + latest: versionsPage_latest_operator + id: versionsPage_id_operator + AND: [versionsPage_where_and] + OR: [versionsPage_where_or] +} + +input versionsPage_parent_operator { + equals: JSON + not_equals: JSON + in: [JSON] + not_in: [JSON] + all: [JSON] + exists: Boolean +} + +""" +The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). +""" +scalar JSON @specifiedBy(url: "http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf") + +input versionsPage_version__text_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input versionsPage_version__updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input versionsPage_version__createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input versionsPage_version___status_operator { + equals: versionsPage_version___status_Input + not_equals: versionsPage_version___status_Input + in: [versionsPage_version___status_Input] + not_in: [versionsPage_version___status_Input] + all: [versionsPage_version___status_Input] + exists: Boolean +} + +enum versionsPage_version___status_Input { + draft + published +} + +input versionsPage_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input versionsPage_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input versionsPage_latest_operator { + equals: Boolean + not_equals: Boolean + exists: Boolean +} + +input versionsPage_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input versionsPage_where_and { + parent: versionsPage_parent_operator + version__text: versionsPage_version__text_operator + version__updatedAt: versionsPage_version__updatedAt_operator + version__createdAt: versionsPage_version__createdAt_operator + version___status: versionsPage_version___status_operator + createdAt: versionsPage_createdAt_operator + updatedAt: versionsPage_updatedAt_operator + latest: versionsPage_latest_operator + id: versionsPage_id_operator + AND: [versionsPage_where_and] + OR: [versionsPage_where_or] +} + +input versionsPage_where_or { + parent: versionsPage_parent_operator + version__text: versionsPage_version__text_operator + version__updatedAt: versionsPage_version__updatedAt_operator + version__createdAt: versionsPage_version__createdAt_operator + version___status: versionsPage_version___status_operator + createdAt: versionsPage_createdAt_operator + updatedAt: versionsPage_updatedAt_operator + latest: versionsPage_latest_operator + id: versionsPage_id_operator + AND: [versionsPage_where_and] + OR: [versionsPage_where_or] +} + +type User { + id: String! + name: String + roles: [User_roles!] + updatedAt: DateTime + createdAt: DateTime + email: EmailAddress! + resetPasswordToken: String + resetPasswordExpiration: DateTime + salt: String + hash: String + loginAttempts: Float + lockUntil: DateTime +} + +enum User_roles { + is_user + is_admin +} + +""" +A field whose value conforms to the standard internet email address format as specified in HTML Spec: https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address. +""" +scalar EmailAddress @specifiedBy(url: "https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address") + +type Users { + docs: [User] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input User_where { + name: User_name_operator + roles: User_roles_operator + updatedAt: User_updatedAt_operator + createdAt: User_createdAt_operator + email: User_email_operator + id: User_id_operator + AND: [User_where_and] + OR: [User_where_or] +} + +input User_name_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input User_roles_operator { + equals: User_roles_Input + not_equals: User_roles_Input + in: [User_roles_Input] + not_in: [User_roles_Input] + all: [User_roles_Input] + exists: Boolean +} + +enum User_roles_Input { + is_user + is_admin +} + +input User_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input User_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input User_email_operator { + equals: EmailAddress + not_equals: EmailAddress + like: EmailAddress + contains: EmailAddress + in: [EmailAddress] + not_in: [EmailAddress] + all: [EmailAddress] +} + +input User_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input User_where_and { + name: User_name_operator + roles: User_roles_operator + updatedAt: User_updatedAt_operator + createdAt: User_createdAt_operator + email: User_email_operator + id: User_id_operator + AND: [User_where_and] + OR: [User_where_or] +} + +input User_where_or { + name: User_name_operator + roles: User_roles_operator + updatedAt: User_updatedAt_operator + createdAt: User_createdAt_operator + email: User_email_operator + id: User_id_operator + AND: [User_where_and] + OR: [User_where_or] +} + +type countUsers { + totalDocs: Int +} + +type usersDocAccess { + fields: UsersDocAccessFields + create: UsersCreateDocAccess + read: UsersReadDocAccess + update: UsersUpdateDocAccess + delete: UsersDeleteDocAccess + unlock: UsersUnlockDocAccess +} + +type UsersDocAccessFields { + name: UsersDocAccessFields_name + roles: UsersDocAccessFields_roles + updatedAt: UsersDocAccessFields_updatedAt + createdAt: UsersDocAccessFields_createdAt + email: UsersDocAccessFields_email +} + +type UsersDocAccessFields_name { + create: UsersDocAccessFields_name_Create + read: UsersDocAccessFields_name_Read + update: UsersDocAccessFields_name_Update + delete: UsersDocAccessFields_name_Delete +} + +type UsersDocAccessFields_name_Create { + permission: Boolean! +} + +type UsersDocAccessFields_name_Read { + permission: Boolean! +} + +type UsersDocAccessFields_name_Update { + permission: Boolean! +} + +type UsersDocAccessFields_name_Delete { + permission: Boolean! +} + +type UsersDocAccessFields_roles { + create: UsersDocAccessFields_roles_Create + read: UsersDocAccessFields_roles_Read + update: UsersDocAccessFields_roles_Update + delete: UsersDocAccessFields_roles_Delete +} + +type UsersDocAccessFields_roles_Create { + permission: Boolean! +} + +type UsersDocAccessFields_roles_Read { + permission: Boolean! +} + +type UsersDocAccessFields_roles_Update { + permission: Boolean! +} + +type UsersDocAccessFields_roles_Delete { + permission: Boolean! +} + +type UsersDocAccessFields_updatedAt { + create: UsersDocAccessFields_updatedAt_Create + read: UsersDocAccessFields_updatedAt_Read + update: UsersDocAccessFields_updatedAt_Update + delete: UsersDocAccessFields_updatedAt_Delete +} + +type UsersDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type UsersDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type UsersDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type UsersDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt { + create: UsersDocAccessFields_createdAt_Create + read: UsersDocAccessFields_createdAt_Read + update: UsersDocAccessFields_createdAt_Update + delete: UsersDocAccessFields_createdAt_Delete +} + +type UsersDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type UsersDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type UsersDocAccessFields_email { + create: UsersDocAccessFields_email_Create + read: UsersDocAccessFields_email_Read + update: UsersDocAccessFields_email_Update + delete: UsersDocAccessFields_email_Delete +} + +type UsersDocAccessFields_email_Create { + permission: Boolean! +} + +type UsersDocAccessFields_email_Read { + permission: Boolean! +} + +type UsersDocAccessFields_email_Update { + permission: Boolean! +} + +type UsersDocAccessFields_email_Delete { + permission: Boolean! +} + +type UsersCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUnlockDocAccess { + permission: Boolean! + where: JSONObject +} + +type usersMe { + collection: String + exp: Int + strategy: String + token: String + user: User +} + +type PayloadLockedDocument { + id: String! + document(draft: Boolean): PayloadLockedDocument_Document_Relationship + globalSlug: String + user: PayloadLockedDocument_User_Relationship! + updatedAt: DateTime + createdAt: DateTime +} + +type PayloadLockedDocument_Document_Relationship { + relationTo: PayloadLockedDocument_Document_RelationTo + value: PayloadLockedDocument_Document +} + +enum PayloadLockedDocument_Document_RelationTo { + pages + users +} + +union PayloadLockedDocument_Document = Page | User + +type PayloadLockedDocument_User_Relationship { + relationTo: PayloadLockedDocument_User_RelationTo + value: PayloadLockedDocument_User +} + +enum PayloadLockedDocument_User_RelationTo { + users +} + +union PayloadLockedDocument_User = User + +type PayloadLockedDocuments { + docs: [PayloadLockedDocument] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input PayloadLockedDocument_where { + document: PayloadLockedDocument_document_Relation + globalSlug: PayloadLockedDocument_globalSlug_operator + user: PayloadLockedDocument_user_Relation + updatedAt: PayloadLockedDocument_updatedAt_operator + createdAt: PayloadLockedDocument_createdAt_operator + id: PayloadLockedDocument_id_operator + AND: [PayloadLockedDocument_where_and] + OR: [PayloadLockedDocument_where_or] +} + +input PayloadLockedDocument_document_Relation { + relationTo: PayloadLockedDocument_document_Relation_RelationTo + value: JSON +} + +enum PayloadLockedDocument_document_Relation_RelationTo { + pages + users +} + +input PayloadLockedDocument_globalSlug_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input PayloadLockedDocument_user_Relation { + relationTo: PayloadLockedDocument_user_Relation_RelationTo + value: JSON +} + +enum PayloadLockedDocument_user_Relation_RelationTo { + users +} + +input PayloadLockedDocument_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input PayloadLockedDocument_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input PayloadLockedDocument_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input PayloadLockedDocument_where_and { + document: PayloadLockedDocument_document_Relation + globalSlug: PayloadLockedDocument_globalSlug_operator + user: PayloadLockedDocument_user_Relation + updatedAt: PayloadLockedDocument_updatedAt_operator + createdAt: PayloadLockedDocument_createdAt_operator + id: PayloadLockedDocument_id_operator + AND: [PayloadLockedDocument_where_and] + OR: [PayloadLockedDocument_where_or] +} + +input PayloadLockedDocument_where_or { + document: PayloadLockedDocument_document_Relation + globalSlug: PayloadLockedDocument_globalSlug_operator + user: PayloadLockedDocument_user_Relation + updatedAt: PayloadLockedDocument_updatedAt_operator + createdAt: PayloadLockedDocument_createdAt_operator + id: PayloadLockedDocument_id_operator + AND: [PayloadLockedDocument_where_and] + OR: [PayloadLockedDocument_where_or] +} + +type countPayloadLockedDocuments { + totalDocs: Int +} + +type payload_locked_documentsDocAccess { + fields: PayloadLockedDocumentsDocAccessFields + create: PayloadLockedDocumentsCreateDocAccess + read: PayloadLockedDocumentsReadDocAccess + update: PayloadLockedDocumentsUpdateDocAccess + delete: PayloadLockedDocumentsDeleteDocAccess +} + +type PayloadLockedDocumentsDocAccessFields { + document: PayloadLockedDocumentsDocAccessFields_document + globalSlug: PayloadLockedDocumentsDocAccessFields_globalSlug + user: PayloadLockedDocumentsDocAccessFields_user + updatedAt: PayloadLockedDocumentsDocAccessFields_updatedAt + createdAt: PayloadLockedDocumentsDocAccessFields_createdAt +} + +type PayloadLockedDocumentsDocAccessFields_document { + create: PayloadLockedDocumentsDocAccessFields_document_Create + read: PayloadLockedDocumentsDocAccessFields_document_Read + update: PayloadLockedDocumentsDocAccessFields_document_Update + delete: PayloadLockedDocumentsDocAccessFields_document_Delete +} + +type PayloadLockedDocumentsDocAccessFields_document_Create { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_document_Read { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_document_Update { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_document_Delete { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_globalSlug { + create: PayloadLockedDocumentsDocAccessFields_globalSlug_Create + read: PayloadLockedDocumentsDocAccessFields_globalSlug_Read + update: PayloadLockedDocumentsDocAccessFields_globalSlug_Update + delete: PayloadLockedDocumentsDocAccessFields_globalSlug_Delete +} + +type PayloadLockedDocumentsDocAccessFields_globalSlug_Create { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_globalSlug_Read { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_globalSlug_Update { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_globalSlug_Delete { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_user { + create: PayloadLockedDocumentsDocAccessFields_user_Create + read: PayloadLockedDocumentsDocAccessFields_user_Read + update: PayloadLockedDocumentsDocAccessFields_user_Update + delete: PayloadLockedDocumentsDocAccessFields_user_Delete +} + +type PayloadLockedDocumentsDocAccessFields_user_Create { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_user_Read { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_user_Update { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_user_Delete { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_updatedAt { + create: PayloadLockedDocumentsDocAccessFields_updatedAt_Create + read: PayloadLockedDocumentsDocAccessFields_updatedAt_Read + update: PayloadLockedDocumentsDocAccessFields_updatedAt_Update + delete: PayloadLockedDocumentsDocAccessFields_updatedAt_Delete +} + +type PayloadLockedDocumentsDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_createdAt { + create: PayloadLockedDocumentsDocAccessFields_createdAt_Create + read: PayloadLockedDocumentsDocAccessFields_createdAt_Read + update: PayloadLockedDocumentsDocAccessFields_createdAt_Update + delete: PayloadLockedDocumentsDocAccessFields_createdAt_Delete +} + +type PayloadLockedDocumentsDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type PayloadLockedDocumentsDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type PayloadLockedDocumentsCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadLockedDocumentsReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadLockedDocumentsUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadLockedDocumentsDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreference { + id: String! + user: PayloadPreference_User_Relationship! + key: String + value: JSON + updatedAt: DateTime + createdAt: DateTime +} + +type PayloadPreference_User_Relationship { + relationTo: PayloadPreference_User_RelationTo + value: PayloadPreference_User +} + +enum PayloadPreference_User_RelationTo { + users +} + +union PayloadPreference_User = User + +type PayloadPreferences { + docs: [PayloadPreference] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input PayloadPreference_where { + user: PayloadPreference_user_Relation + key: PayloadPreference_key_operator + value: PayloadPreference_value_operator + updatedAt: PayloadPreference_updatedAt_operator + createdAt: PayloadPreference_createdAt_operator + id: PayloadPreference_id_operator + AND: [PayloadPreference_where_and] + OR: [PayloadPreference_where_or] +} + +input PayloadPreference_user_Relation { + relationTo: PayloadPreference_user_Relation_RelationTo + value: JSON +} + +enum PayloadPreference_user_Relation_RelationTo { + users +} + +input PayloadPreference_key_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input PayloadPreference_value_operator { + equals: JSON + not_equals: JSON + like: JSON + contains: JSON + within: JSON + intersects: JSON + exists: Boolean +} + +input PayloadPreference_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input PayloadPreference_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input PayloadPreference_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input PayloadPreference_where_and { + user: PayloadPreference_user_Relation + key: PayloadPreference_key_operator + value: PayloadPreference_value_operator + updatedAt: PayloadPreference_updatedAt_operator + createdAt: PayloadPreference_createdAt_operator + id: PayloadPreference_id_operator + AND: [PayloadPreference_where_and] + OR: [PayloadPreference_where_or] +} + +input PayloadPreference_where_or { + user: PayloadPreference_user_Relation + key: PayloadPreference_key_operator + value: PayloadPreference_value_operator + updatedAt: PayloadPreference_updatedAt_operator + createdAt: PayloadPreference_createdAt_operator + id: PayloadPreference_id_operator + AND: [PayloadPreference_where_and] + OR: [PayloadPreference_where_or] +} + +type countPayloadPreferences { + totalDocs: Int +} + +type payload_preferencesDocAccess { + fields: PayloadPreferencesDocAccessFields + create: PayloadPreferencesCreateDocAccess + read: PayloadPreferencesReadDocAccess + update: PayloadPreferencesUpdateDocAccess + delete: PayloadPreferencesDeleteDocAccess +} + +type PayloadPreferencesDocAccessFields { + user: PayloadPreferencesDocAccessFields_user + key: PayloadPreferencesDocAccessFields_key + value: PayloadPreferencesDocAccessFields_value + updatedAt: PayloadPreferencesDocAccessFields_updatedAt + createdAt: PayloadPreferencesDocAccessFields_createdAt +} + +type PayloadPreferencesDocAccessFields_user { + create: PayloadPreferencesDocAccessFields_user_Create + read: PayloadPreferencesDocAccessFields_user_Read + update: PayloadPreferencesDocAccessFields_user_Update + delete: PayloadPreferencesDocAccessFields_user_Delete +} + +type PayloadPreferencesDocAccessFields_user_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_user_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_user_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_user_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key { + create: PayloadPreferencesDocAccessFields_key_Create + read: PayloadPreferencesDocAccessFields_key_Read + update: PayloadPreferencesDocAccessFields_key_Update + delete: PayloadPreferencesDocAccessFields_key_Delete +} + +type PayloadPreferencesDocAccessFields_key_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_key_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value { + create: PayloadPreferencesDocAccessFields_value_Create + read: PayloadPreferencesDocAccessFields_value_Read + update: PayloadPreferencesDocAccessFields_value_Update + delete: PayloadPreferencesDocAccessFields_value_Delete +} + +type PayloadPreferencesDocAccessFields_value_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_value_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt { + create: PayloadPreferencesDocAccessFields_updatedAt_Create + read: PayloadPreferencesDocAccessFields_updatedAt_Read + update: PayloadPreferencesDocAccessFields_updatedAt_Update + delete: PayloadPreferencesDocAccessFields_updatedAt_Delete +} + +type PayloadPreferencesDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt { + create: PayloadPreferencesDocAccessFields_createdAt_Create + read: PayloadPreferencesDocAccessFields_createdAt_Read + update: PayloadPreferencesDocAccessFields_createdAt_Update + delete: PayloadPreferencesDocAccessFields_createdAt_Delete +} + +type PayloadPreferencesDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type PayloadPreferencesDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadListFilter { + id: String! + Name: String! + Where: JSON! + Columns: JSON + Collection: PayloadListFilter_Collection! + updatedAt: DateTime + createdAt: DateTime +} + +enum PayloadListFilter_Collection { + pages + users + payload_locked_documents + payload_preferences +} + +type PayloadListFilters { + docs: [PayloadListFilter] + hasNextPage: Boolean + hasPrevPage: Boolean + limit: Int + nextPage: Int + offset: Int + page: Int + pagingCounter: Int + prevPage: Int + totalDocs: Int + totalPages: Int +} + +input PayloadListFilter_where { + Name: PayloadListFilter_Name_operator + Where: PayloadListFilter_Where_operator + Columns: PayloadListFilter_Columns_operator + Collection: PayloadListFilter_Collection_operator + updatedAt: PayloadListFilter_updatedAt_operator + createdAt: PayloadListFilter_createdAt_operator + id: PayloadListFilter_id_operator + AND: [PayloadListFilter_where_and] + OR: [PayloadListFilter_where_or] +} + +input PayloadListFilter_Name_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] +} + +input PayloadListFilter_Where_operator { + equals: JSON + not_equals: JSON + like: JSON + contains: JSON + within: JSON + intersects: JSON +} + +input PayloadListFilter_Columns_operator { + equals: JSON + not_equals: JSON + like: JSON + contains: JSON + within: JSON + intersects: JSON + exists: Boolean +} + +input PayloadListFilter_Collection_operator { + equals: PayloadListFilter_Collection_Input + not_equals: PayloadListFilter_Collection_Input + in: [PayloadListFilter_Collection_Input] + not_in: [PayloadListFilter_Collection_Input] + all: [PayloadListFilter_Collection_Input] +} + +enum PayloadListFilter_Collection_Input { + pages + users + payload_locked_documents + payload_preferences +} + +input PayloadListFilter_updatedAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input PayloadListFilter_createdAt_operator { + equals: DateTime + not_equals: DateTime + greater_than_equal: DateTime + greater_than: DateTime + less_than_equal: DateTime + less_than: DateTime + like: DateTime + exists: Boolean +} + +input PayloadListFilter_id_operator { + equals: String + not_equals: String + like: String + contains: String + in: [String] + not_in: [String] + all: [String] + exists: Boolean +} + +input PayloadListFilter_where_and { + Name: PayloadListFilter_Name_operator + Where: PayloadListFilter_Where_operator + Columns: PayloadListFilter_Columns_operator + Collection: PayloadListFilter_Collection_operator + updatedAt: PayloadListFilter_updatedAt_operator + createdAt: PayloadListFilter_createdAt_operator + id: PayloadListFilter_id_operator + AND: [PayloadListFilter_where_and] + OR: [PayloadListFilter_where_or] +} + +input PayloadListFilter_where_or { + Name: PayloadListFilter_Name_operator + Where: PayloadListFilter_Where_operator + Columns: PayloadListFilter_Columns_operator + Collection: PayloadListFilter_Collection_operator + updatedAt: PayloadListFilter_updatedAt_operator + createdAt: PayloadListFilter_createdAt_operator + id: PayloadListFilter_id_operator + AND: [PayloadListFilter_where_and] + OR: [PayloadListFilter_where_or] +} + +type countPayloadListFilters { + totalDocs: Int +} + +type payload_list_filtersDocAccess { + fields: PayloadListFiltersDocAccessFields + create: PayloadListFiltersCreateDocAccess + read: PayloadListFiltersReadDocAccess + update: PayloadListFiltersUpdateDocAccess + delete: PayloadListFiltersDeleteDocAccess +} + +type PayloadListFiltersDocAccessFields { + Name: PayloadListFiltersDocAccessFields_Name + Where: PayloadListFiltersDocAccessFields_Where + Columns: PayloadListFiltersDocAccessFields_Columns + Collection: PayloadListFiltersDocAccessFields_Collection + updatedAt: PayloadListFiltersDocAccessFields_updatedAt + createdAt: PayloadListFiltersDocAccessFields_createdAt +} + +type PayloadListFiltersDocAccessFields_Name { + create: PayloadListFiltersDocAccessFields_Name_Create + read: PayloadListFiltersDocAccessFields_Name_Read + update: PayloadListFiltersDocAccessFields_Name_Update + delete: PayloadListFiltersDocAccessFields_Name_Delete +} + +type PayloadListFiltersDocAccessFields_Name_Create { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Name_Read { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Name_Update { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Name_Delete { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Where { + create: PayloadListFiltersDocAccessFields_Where_Create + read: PayloadListFiltersDocAccessFields_Where_Read + update: PayloadListFiltersDocAccessFields_Where_Update + delete: PayloadListFiltersDocAccessFields_Where_Delete +} + +type PayloadListFiltersDocAccessFields_Where_Create { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Where_Read { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Where_Update { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Where_Delete { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Columns { + create: PayloadListFiltersDocAccessFields_Columns_Create + read: PayloadListFiltersDocAccessFields_Columns_Read + update: PayloadListFiltersDocAccessFields_Columns_Update + delete: PayloadListFiltersDocAccessFields_Columns_Delete +} + +type PayloadListFiltersDocAccessFields_Columns_Create { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Columns_Read { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Columns_Update { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Columns_Delete { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Collection { + create: PayloadListFiltersDocAccessFields_Collection_Create + read: PayloadListFiltersDocAccessFields_Collection_Read + update: PayloadListFiltersDocAccessFields_Collection_Update + delete: PayloadListFiltersDocAccessFields_Collection_Delete +} + +type PayloadListFiltersDocAccessFields_Collection_Create { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Collection_Read { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Collection_Update { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_Collection_Delete { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_updatedAt { + create: PayloadListFiltersDocAccessFields_updatedAt_Create + read: PayloadListFiltersDocAccessFields_updatedAt_Read + update: PayloadListFiltersDocAccessFields_updatedAt_Update + delete: PayloadListFiltersDocAccessFields_updatedAt_Delete +} + +type PayloadListFiltersDocAccessFields_updatedAt_Create { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_updatedAt_Read { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_updatedAt_Update { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_updatedAt_Delete { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_createdAt { + create: PayloadListFiltersDocAccessFields_createdAt_Create + read: PayloadListFiltersDocAccessFields_createdAt_Read + update: PayloadListFiltersDocAccessFields_createdAt_Update + delete: PayloadListFiltersDocAccessFields_createdAt_Delete +} + +type PayloadListFiltersDocAccessFields_createdAt_Create { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_createdAt_Read { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_createdAt_Update { + permission: Boolean! +} + +type PayloadListFiltersDocAccessFields_createdAt_Delete { + permission: Boolean! +} + +type PayloadListFiltersCreateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadListFiltersReadDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadListFiltersUpdateDocAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadListFiltersDeleteDocAccess { + permission: Boolean! + where: JSONObject +} + +type Access { + canAccessAdmin: Boolean! + pages: pagesAccess + users: usersAccess + payload_locked_documents: payload_locked_documentsAccess + payload_preferences: payload_preferencesAccess + payload_list_filters: payload_list_filtersAccess +} + +type pagesAccess { + fields: PagesFields + create: PagesCreateAccess + read: PagesReadAccess + update: PagesUpdateAccess + delete: PagesDeleteAccess + readVersions: PagesReadVersionsAccess +} + +type PagesFields { + text: PagesFields_text + updatedAt: PagesFields_updatedAt + createdAt: PagesFields_createdAt + _status: PagesFields__status +} + +type PagesFields_text { + create: PagesFields_text_Create + read: PagesFields_text_Read + update: PagesFields_text_Update + delete: PagesFields_text_Delete +} + +type PagesFields_text_Create { + permission: Boolean! +} + +type PagesFields_text_Read { + permission: Boolean! +} + +type PagesFields_text_Update { + permission: Boolean! +} + +type PagesFields_text_Delete { + permission: Boolean! +} + +type PagesFields_updatedAt { + create: PagesFields_updatedAt_Create + read: PagesFields_updatedAt_Read + update: PagesFields_updatedAt_Update + delete: PagesFields_updatedAt_Delete +} + +type PagesFields_updatedAt_Create { + permission: Boolean! +} + +type PagesFields_updatedAt_Read { + permission: Boolean! +} + +type PagesFields_updatedAt_Update { + permission: Boolean! +} + +type PagesFields_updatedAt_Delete { + permission: Boolean! +} + +type PagesFields_createdAt { + create: PagesFields_createdAt_Create + read: PagesFields_createdAt_Read + update: PagesFields_createdAt_Update + delete: PagesFields_createdAt_Delete +} + +type PagesFields_createdAt_Create { + permission: Boolean! +} + +type PagesFields_createdAt_Read { + permission: Boolean! +} + +type PagesFields_createdAt_Update { + permission: Boolean! +} + +type PagesFields_createdAt_Delete { + permission: Boolean! +} + +type PagesFields__status { + create: PagesFields__status_Create + read: PagesFields__status_Read + update: PagesFields__status_Update + delete: PagesFields__status_Delete +} + +type PagesFields__status_Create { + permission: Boolean! +} + +type PagesFields__status_Read { + permission: Boolean! +} + +type PagesFields__status_Update { + permission: Boolean! +} + +type PagesFields__status_Delete { + permission: Boolean! +} + +type PagesCreateAccess { + permission: Boolean! + where: JSONObject +} + +type PagesReadAccess { + permission: Boolean! + where: JSONObject +} + +type PagesUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type PagesDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type PagesReadVersionsAccess { + permission: Boolean! + where: JSONObject +} + +type usersAccess { + fields: UsersFields + create: UsersCreateAccess + read: UsersReadAccess + update: UsersUpdateAccess + delete: UsersDeleteAccess + unlock: UsersUnlockAccess +} + +type UsersFields { + name: UsersFields_name + roles: UsersFields_roles + updatedAt: UsersFields_updatedAt + createdAt: UsersFields_createdAt + email: UsersFields_email +} + +type UsersFields_name { + create: UsersFields_name_Create + read: UsersFields_name_Read + update: UsersFields_name_Update + delete: UsersFields_name_Delete +} + +type UsersFields_name_Create { + permission: Boolean! +} + +type UsersFields_name_Read { + permission: Boolean! +} + +type UsersFields_name_Update { + permission: Boolean! +} + +type UsersFields_name_Delete { + permission: Boolean! +} + +type UsersFields_roles { + create: UsersFields_roles_Create + read: UsersFields_roles_Read + update: UsersFields_roles_Update + delete: UsersFields_roles_Delete +} + +type UsersFields_roles_Create { + permission: Boolean! +} + +type UsersFields_roles_Read { + permission: Boolean! +} + +type UsersFields_roles_Update { + permission: Boolean! +} + +type UsersFields_roles_Delete { + permission: Boolean! +} + +type UsersFields_updatedAt { + create: UsersFields_updatedAt_Create + read: UsersFields_updatedAt_Read + update: UsersFields_updatedAt_Update + delete: UsersFields_updatedAt_Delete +} + +type UsersFields_updatedAt_Create { + permission: Boolean! +} + +type UsersFields_updatedAt_Read { + permission: Boolean! +} + +type UsersFields_updatedAt_Update { + permission: Boolean! +} + +type UsersFields_updatedAt_Delete { + permission: Boolean! +} + +type UsersFields_createdAt { + create: UsersFields_createdAt_Create + read: UsersFields_createdAt_Read + update: UsersFields_createdAt_Update + delete: UsersFields_createdAt_Delete +} + +type UsersFields_createdAt_Create { + permission: Boolean! +} + +type UsersFields_createdAt_Read { + permission: Boolean! +} + +type UsersFields_createdAt_Update { + permission: Boolean! +} + +type UsersFields_createdAt_Delete { + permission: Boolean! +} + +type UsersFields_email { + create: UsersFields_email_Create + read: UsersFields_email_Read + update: UsersFields_email_Update + delete: UsersFields_email_Delete +} + +type UsersFields_email_Create { + permission: Boolean! +} + +type UsersFields_email_Read { + permission: Boolean! +} + +type UsersFields_email_Update { + permission: Boolean! +} + +type UsersFields_email_Delete { + permission: Boolean! +} + +type UsersCreateAccess { + permission: Boolean! + where: JSONObject +} + +type UsersReadAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type UsersDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type UsersUnlockAccess { + permission: Boolean! + where: JSONObject +} + +type payload_locked_documentsAccess { + fields: PayloadLockedDocumentsFields + create: PayloadLockedDocumentsCreateAccess + read: PayloadLockedDocumentsReadAccess + update: PayloadLockedDocumentsUpdateAccess + delete: PayloadLockedDocumentsDeleteAccess +} + +type PayloadLockedDocumentsFields { + document: PayloadLockedDocumentsFields_document + globalSlug: PayloadLockedDocumentsFields_globalSlug + user: PayloadLockedDocumentsFields_user + updatedAt: PayloadLockedDocumentsFields_updatedAt + createdAt: PayloadLockedDocumentsFields_createdAt +} + +type PayloadLockedDocumentsFields_document { + create: PayloadLockedDocumentsFields_document_Create + read: PayloadLockedDocumentsFields_document_Read + update: PayloadLockedDocumentsFields_document_Update + delete: PayloadLockedDocumentsFields_document_Delete +} + +type PayloadLockedDocumentsFields_document_Create { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_document_Read { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_document_Update { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_document_Delete { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_globalSlug { + create: PayloadLockedDocumentsFields_globalSlug_Create + read: PayloadLockedDocumentsFields_globalSlug_Read + update: PayloadLockedDocumentsFields_globalSlug_Update + delete: PayloadLockedDocumentsFields_globalSlug_Delete +} + +type PayloadLockedDocumentsFields_globalSlug_Create { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_globalSlug_Read { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_globalSlug_Update { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_globalSlug_Delete { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_user { + create: PayloadLockedDocumentsFields_user_Create + read: PayloadLockedDocumentsFields_user_Read + update: PayloadLockedDocumentsFields_user_Update + delete: PayloadLockedDocumentsFields_user_Delete +} + +type PayloadLockedDocumentsFields_user_Create { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_user_Read { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_user_Update { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_user_Delete { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_updatedAt { + create: PayloadLockedDocumentsFields_updatedAt_Create + read: PayloadLockedDocumentsFields_updatedAt_Read + update: PayloadLockedDocumentsFields_updatedAt_Update + delete: PayloadLockedDocumentsFields_updatedAt_Delete +} + +type PayloadLockedDocumentsFields_updatedAt_Create { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_updatedAt_Read { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_updatedAt_Update { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_updatedAt_Delete { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_createdAt { + create: PayloadLockedDocumentsFields_createdAt_Create + read: PayloadLockedDocumentsFields_createdAt_Read + update: PayloadLockedDocumentsFields_createdAt_Update + delete: PayloadLockedDocumentsFields_createdAt_Delete +} + +type PayloadLockedDocumentsFields_createdAt_Create { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_createdAt_Read { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_createdAt_Update { + permission: Boolean! +} + +type PayloadLockedDocumentsFields_createdAt_Delete { + permission: Boolean! +} + +type PayloadLockedDocumentsCreateAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadLockedDocumentsReadAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadLockedDocumentsUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadLockedDocumentsDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type payload_preferencesAccess { + fields: PayloadPreferencesFields + create: PayloadPreferencesCreateAccess + read: PayloadPreferencesReadAccess + update: PayloadPreferencesUpdateAccess + delete: PayloadPreferencesDeleteAccess +} + +type PayloadPreferencesFields { + user: PayloadPreferencesFields_user + key: PayloadPreferencesFields_key + value: PayloadPreferencesFields_value + updatedAt: PayloadPreferencesFields_updatedAt + createdAt: PayloadPreferencesFields_createdAt +} + +type PayloadPreferencesFields_user { + create: PayloadPreferencesFields_user_Create + read: PayloadPreferencesFields_user_Read + update: PayloadPreferencesFields_user_Update + delete: PayloadPreferencesFields_user_Delete +} + +type PayloadPreferencesFields_user_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_user_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_user_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_user_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_key { + create: PayloadPreferencesFields_key_Create + read: PayloadPreferencesFields_key_Read + update: PayloadPreferencesFields_key_Update + delete: PayloadPreferencesFields_key_Delete +} + +type PayloadPreferencesFields_key_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_key_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_key_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_key_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_value { + create: PayloadPreferencesFields_value_Create + read: PayloadPreferencesFields_value_Read + update: PayloadPreferencesFields_value_Update + delete: PayloadPreferencesFields_value_Delete +} + +type PayloadPreferencesFields_value_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_value_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_value_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_value_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt { + create: PayloadPreferencesFields_updatedAt_Create + read: PayloadPreferencesFields_updatedAt_Read + update: PayloadPreferencesFields_updatedAt_Update + delete: PayloadPreferencesFields_updatedAt_Delete +} + +type PayloadPreferencesFields_updatedAt_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_updatedAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt { + create: PayloadPreferencesFields_createdAt_Create + read: PayloadPreferencesFields_createdAt_Read + update: PayloadPreferencesFields_createdAt_Update + delete: PayloadPreferencesFields_createdAt_Delete +} + +type PayloadPreferencesFields_createdAt_Create { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt_Read { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt_Update { + permission: Boolean! +} + +type PayloadPreferencesFields_createdAt_Delete { + permission: Boolean! +} + +type PayloadPreferencesCreateAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesReadAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadPreferencesDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type payload_list_filtersAccess { + fields: PayloadListFiltersFields + create: PayloadListFiltersCreateAccess + read: PayloadListFiltersReadAccess + update: PayloadListFiltersUpdateAccess + delete: PayloadListFiltersDeleteAccess +} + +type PayloadListFiltersFields { + Name: PayloadListFiltersFields_Name + Where: PayloadListFiltersFields_Where + Columns: PayloadListFiltersFields_Columns + Collection: PayloadListFiltersFields_Collection + updatedAt: PayloadListFiltersFields_updatedAt + createdAt: PayloadListFiltersFields_createdAt +} + +type PayloadListFiltersFields_Name { + create: PayloadListFiltersFields_Name_Create + read: PayloadListFiltersFields_Name_Read + update: PayloadListFiltersFields_Name_Update + delete: PayloadListFiltersFields_Name_Delete +} + +type PayloadListFiltersFields_Name_Create { + permission: Boolean! +} + +type PayloadListFiltersFields_Name_Read { + permission: Boolean! +} + +type PayloadListFiltersFields_Name_Update { + permission: Boolean! +} + +type PayloadListFiltersFields_Name_Delete { + permission: Boolean! +} + +type PayloadListFiltersFields_Where { + create: PayloadListFiltersFields_Where_Create + read: PayloadListFiltersFields_Where_Read + update: PayloadListFiltersFields_Where_Update + delete: PayloadListFiltersFields_Where_Delete +} + +type PayloadListFiltersFields_Where_Create { + permission: Boolean! +} + +type PayloadListFiltersFields_Where_Read { + permission: Boolean! +} + +type PayloadListFiltersFields_Where_Update { + permission: Boolean! +} + +type PayloadListFiltersFields_Where_Delete { + permission: Boolean! +} + +type PayloadListFiltersFields_Columns { + create: PayloadListFiltersFields_Columns_Create + read: PayloadListFiltersFields_Columns_Read + update: PayloadListFiltersFields_Columns_Update + delete: PayloadListFiltersFields_Columns_Delete +} + +type PayloadListFiltersFields_Columns_Create { + permission: Boolean! +} + +type PayloadListFiltersFields_Columns_Read { + permission: Boolean! +} + +type PayloadListFiltersFields_Columns_Update { + permission: Boolean! +} + +type PayloadListFiltersFields_Columns_Delete { + permission: Boolean! +} + +type PayloadListFiltersFields_Collection { + create: PayloadListFiltersFields_Collection_Create + read: PayloadListFiltersFields_Collection_Read + update: PayloadListFiltersFields_Collection_Update + delete: PayloadListFiltersFields_Collection_Delete +} + +type PayloadListFiltersFields_Collection_Create { + permission: Boolean! +} + +type PayloadListFiltersFields_Collection_Read { + permission: Boolean! +} + +type PayloadListFiltersFields_Collection_Update { + permission: Boolean! +} + +type PayloadListFiltersFields_Collection_Delete { + permission: Boolean! +} + +type PayloadListFiltersFields_updatedAt { + create: PayloadListFiltersFields_updatedAt_Create + read: PayloadListFiltersFields_updatedAt_Read + update: PayloadListFiltersFields_updatedAt_Update + delete: PayloadListFiltersFields_updatedAt_Delete +} + +type PayloadListFiltersFields_updatedAt_Create { + permission: Boolean! +} + +type PayloadListFiltersFields_updatedAt_Read { + permission: Boolean! +} + +type PayloadListFiltersFields_updatedAt_Update { + permission: Boolean! +} + +type PayloadListFiltersFields_updatedAt_Delete { + permission: Boolean! +} + +type PayloadListFiltersFields_createdAt { + create: PayloadListFiltersFields_createdAt_Create + read: PayloadListFiltersFields_createdAt_Read + update: PayloadListFiltersFields_createdAt_Update + delete: PayloadListFiltersFields_createdAt_Delete +} + +type PayloadListFiltersFields_createdAt_Create { + permission: Boolean! +} + +type PayloadListFiltersFields_createdAt_Read { + permission: Boolean! +} + +type PayloadListFiltersFields_createdAt_Update { + permission: Boolean! +} + +type PayloadListFiltersFields_createdAt_Delete { + permission: Boolean! +} + +type PayloadListFiltersCreateAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadListFiltersReadAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadListFiltersUpdateAccess { + permission: Boolean! + where: JSONObject +} + +type PayloadListFiltersDeleteAccess { + permission: Boolean! + where: JSONObject +} + +type Mutation { + createPage(data: mutationPageInput!, draft: Boolean): Page + updatePage(id: String!, autosave: Boolean, data: mutationPageUpdateInput!, draft: Boolean): Page + deletePage(id: String!): Page + duplicatePage(id: String!, data: mutationPageInput!): Page + restoreVersionPage(id: String, draft: Boolean): Page + createUser(data: mutationUserInput!, draft: Boolean): User + updateUser(id: String!, autosave: Boolean, data: mutationUserUpdateInput!, draft: Boolean): User + deleteUser(id: String!): User + refreshTokenUser: usersRefreshedUser + logoutUser: String + unlockUser(email: String!): Boolean! + loginUser(email: String!, password: String): usersLoginResult + forgotPasswordUser(disableEmail: Boolean, expiration: Int, email: String!): Boolean! + resetPasswordUser(password: String, token: String): usersResetPassword + verifyEmailUser(token: String): Boolean + createPayloadLockedDocument(data: mutationPayloadLockedDocumentInput!, draft: Boolean): PayloadLockedDocument + updatePayloadLockedDocument(id: String!, autosave: Boolean, data: mutationPayloadLockedDocumentUpdateInput!, draft: Boolean): PayloadLockedDocument + deletePayloadLockedDocument(id: String!): PayloadLockedDocument + duplicatePayloadLockedDocument(id: String!, data: mutationPayloadLockedDocumentInput!): PayloadLockedDocument + createPayloadPreference(data: mutationPayloadPreferenceInput!, draft: Boolean): PayloadPreference + updatePayloadPreference(id: String!, autosave: Boolean, data: mutationPayloadPreferenceUpdateInput!, draft: Boolean): PayloadPreference + deletePayloadPreference(id: String!): PayloadPreference + duplicatePayloadPreference(id: String!, data: mutationPayloadPreferenceInput!): PayloadPreference + createPayloadListFilter(data: mutationPayloadListFilterInput!, draft: Boolean): PayloadListFilter + updatePayloadListFilter(id: String!, autosave: Boolean, data: mutationPayloadListFilterUpdateInput!, draft: Boolean): PayloadListFilter + deletePayloadListFilter(id: String!): PayloadListFilter + duplicatePayloadListFilter(id: String!, data: mutationPayloadListFilterInput!): PayloadListFilter +} + +input mutationPageInput { + text: String + updatedAt: String + createdAt: String + _status: Page__status_MutationInput +} + +enum Page__status_MutationInput { + draft + published +} + +input mutationPageUpdateInput { + text: String + updatedAt: String + createdAt: String + _status: PageUpdate__status_MutationInput +} + +enum PageUpdate__status_MutationInput { + draft + published +} + +input mutationUserInput { + name: String + roles: [User_roles_MutationInput] + updatedAt: String + createdAt: String + email: String! + resetPasswordToken: String + resetPasswordExpiration: String + salt: String + hash: String + loginAttempts: Float + lockUntil: String + password: String! +} + +enum User_roles_MutationInput { + is_user + is_admin +} + +input mutationUserUpdateInput { + name: String + roles: [UserUpdate_roles_MutationInput] + updatedAt: String + createdAt: String + email: String + resetPasswordToken: String + resetPasswordExpiration: String + salt: String + hash: String + loginAttempts: Float + lockUntil: String + password: String +} + +enum UserUpdate_roles_MutationInput { + is_user + is_admin +} + +type usersRefreshedUser { + exp: Int + refreshedToken: String + strategy: String + user: usersJWT +} + +type usersJWT { + email: EmailAddress! + collection: String! +} + +type usersLoginResult { + exp: Int + token: String + user: User +} + +type usersResetPassword { + token: String + user: User +} + +input mutationPayloadLockedDocumentInput { + document: PayloadLockedDocument_DocumentRelationshipInput + globalSlug: String + user: PayloadLockedDocument_UserRelationshipInput + updatedAt: String + createdAt: String +} + +input PayloadLockedDocument_DocumentRelationshipInput { + relationTo: PayloadLockedDocument_DocumentRelationshipInputRelationTo + value: JSON +} + +enum PayloadLockedDocument_DocumentRelationshipInputRelationTo { + pages + users +} + +input PayloadLockedDocument_UserRelationshipInput { + relationTo: PayloadLockedDocument_UserRelationshipInputRelationTo + value: JSON +} + +enum PayloadLockedDocument_UserRelationshipInputRelationTo { + users +} + +input mutationPayloadLockedDocumentUpdateInput { + document: PayloadLockedDocumentUpdate_DocumentRelationshipInput + globalSlug: String + user: PayloadLockedDocumentUpdate_UserRelationshipInput + updatedAt: String + createdAt: String +} + +input PayloadLockedDocumentUpdate_DocumentRelationshipInput { + relationTo: PayloadLockedDocumentUpdate_DocumentRelationshipInputRelationTo + value: JSON +} + +enum PayloadLockedDocumentUpdate_DocumentRelationshipInputRelationTo { + pages + users +} + +input PayloadLockedDocumentUpdate_UserRelationshipInput { + relationTo: PayloadLockedDocumentUpdate_UserRelationshipInputRelationTo + value: JSON +} + +enum PayloadLockedDocumentUpdate_UserRelationshipInputRelationTo { + users +} + +input mutationPayloadPreferenceInput { + user: PayloadPreference_UserRelationshipInput + key: String + value: JSON + updatedAt: String + createdAt: String +} + +input PayloadPreference_UserRelationshipInput { + relationTo: PayloadPreference_UserRelationshipInputRelationTo + value: JSON +} + +enum PayloadPreference_UserRelationshipInputRelationTo { + users +} + +input mutationPayloadPreferenceUpdateInput { + user: PayloadPreferenceUpdate_UserRelationshipInput + key: String + value: JSON + updatedAt: String + createdAt: String +} + +input PayloadPreferenceUpdate_UserRelationshipInput { + relationTo: PayloadPreferenceUpdate_UserRelationshipInputRelationTo + value: JSON +} + +enum PayloadPreferenceUpdate_UserRelationshipInputRelationTo { + users +} + +input mutationPayloadListFilterInput { + Name: String! + Where: JSON! + Columns: JSON + Collection: PayloadListFilter_Collection_MutationInput! + updatedAt: String + createdAt: String +} + +enum PayloadListFilter_Collection_MutationInput { + pages + users + payload_locked_documents + payload_preferences +} + +input mutationPayloadListFilterUpdateInput { + Name: String + Where: JSON + Columns: JSON + Collection: PayloadListFilterUpdate_Collection_MutationInput + updatedAt: String + createdAt: String +} + +enum PayloadListFilterUpdate_Collection_MutationInput { + pages + users + payload_locked_documents + payload_preferences +} \ No newline at end of file diff --git a/test/query-presets/seed.ts b/test/query-presets/seed.ts new file mode 100644 index 0000000000..38f116f67e --- /dev/null +++ b/test/query-presets/seed.ts @@ -0,0 +1,182 @@ +import type { Payload, QueryPreset } from 'payload' + +import { devUser as devCredentials, regularUser as regularCredentials } from '../credentials.js' +import { executePromises } from '../helpers/executePromises.js' +import { seedDB } from '../helpers/seed.js' +import { collectionSlugs, pagesSlug, usersSlug } from './slugs.js' + +type SeededQueryPreset = { + relatedCollection: 'pages' +} & Omit + +export const seedData: { + everyone: SeededQueryPreset + onlyMe: SeededQueryPreset + specificUsers: (args: { userID: string }) => SeededQueryPreset +} = { + onlyMe: { + relatedCollection: pagesSlug, + isShared: false, + title: 'Only Me', + columns: [ + { + accessor: 'text', + active: true, + }, + ], + access: { + delete: { + constraint: 'onlyMe', + }, + update: { + constraint: 'onlyMe', + }, + read: { + constraint: 'onlyMe', + }, + }, + where: { + text: { + equals: 'example page', + }, + }, + }, + everyone: { + relatedCollection: pagesSlug, + isShared: true, + title: 'Everyone', + access: { + delete: { + constraint: 'everyone', + }, + update: { + constraint: 'everyone', + }, + read: { + constraint: 'everyone', + }, + }, + columns: [ + { + accessor: 'text', + active: true, + }, + ], + where: { + text: { + equals: 'example page', + }, + }, + }, + specificUsers: ({ userID }: { userID: string }) => ({ + title: 'Specific Users', + isShared: true, + where: { + text: { + equals: 'example page', + }, + }, + access: { + read: { + constraint: 'specificUsers', + users: [userID], + }, + update: { + constraint: 'specificUsers', + users: [userID], + }, + delete: { + constraint: 'specificUsers', + users: [userID], + }, + }, + columns: [ + { + accessor: 'text', + active: true, + }, + ], + relatedCollection: pagesSlug, + }), +} + +export const seed = async (_payload: Payload) => { + const [devUser] = await executePromises( + [ + () => + _payload.create({ + collection: usersSlug, + data: { + email: devCredentials.email, + password: devCredentials.password, + name: 'Admin', + roles: ['admin'], + }, + }), + () => + _payload.create({ + collection: usersSlug, + data: { + email: regularCredentials.email, + password: regularCredentials.password, + name: 'User', + roles: ['user'], + }, + }), + () => + _payload.create({ + collection: usersSlug, + data: { + email: 'anonymous@email.com', + password: regularCredentials.password, + name: 'User', + roles: ['anonymous'], + }, + }), + ], + false, + ) + + await executePromises( + [ + () => + _payload.create({ + collection: pagesSlug, + data: { + text: 'example page', + }, + }), + () => + _payload.create({ + collection: 'payload-query-presets', + user: devUser, + overrideAccess: false, + data: seedData.specificUsers({ userID: devUser?.id || '' }), + }), + () => + _payload.create({ + collection: 'payload-query-presets', + user: devUser, + overrideAccess: false, + data: seedData.everyone, + }), + () => + _payload.create({ + collection: 'payload-query-presets', + user: devUser, + overrideAccess: false, + data: seedData.onlyMe, + }), + ], + false, + ) +} + +export async function clearAndSeedEverything(_payload: Payload) { + return await seedDB({ + _payload, + collectionSlugs, + seedFunction: seed, + snapshotKey: 'adminTests', + }) +} diff --git a/test/query-presets/slugs.ts b/test/query-presets/slugs.ts new file mode 100644 index 0000000000..85c6ec36fd --- /dev/null +++ b/test/query-presets/slugs.ts @@ -0,0 +1,5 @@ +export const usersSlug = 'users' + +export const pagesSlug = 'pages' + +export const collectionSlugs = [usersSlug, pagesSlug] diff --git a/test/query-presets/tsconfig.eslint.json b/test/query-presets/tsconfig.eslint.json new file mode 100644 index 0000000000..b34cc7afbb --- /dev/null +++ b/test/query-presets/tsconfig.eslint.json @@ -0,0 +1,13 @@ +{ + // extend your base config to share compilerOptions, etc + //"extends": "./tsconfig.json", + "compilerOptions": { + // ensure that nobody can accidentally use this config for a build + "noEmit": true + }, + "include": [ + // whatever paths you intend to lint + "./**/*.ts", + "./**/*.tsx" + ] +} diff --git a/test/query-presets/tsconfig.json b/test/query-presets/tsconfig.json new file mode 100644 index 0000000000..3c43903cfd --- /dev/null +++ b/test/query-presets/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../tsconfig.json" +} diff --git a/test/sort/int.spec.ts b/test/sort/int.spec.ts index 730f7b4895..805370aa11 100644 --- a/test/sort/int.spec.ts +++ b/test/sort/int.spec.ts @@ -15,8 +15,8 @@ const dirname = path.dirname(filename) describe('Sort', () => { beforeAll(async () => { - const initialized = await initPayloadInt(dirname) - ;({ payload, restClient } = initialized) + // @ts-expect-error: initPayloadInt does not have a proper type definition + ;({ payload, restClient } = await initPayloadInt(dirname)) }) afterAll(async () => { diff --git a/tsconfig.base.json b/tsconfig.base.json index c9793d25c6..daa36c7211 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -31,7 +31,7 @@ } ], "paths": { - "@payload-config": ["./test/_community/config.ts"], + "@payload-config": ["./test/query-presets/config.ts"], "@payloadcms/admin-bar": ["./packages/admin-bar/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],