diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 755f7acde..e8d852eed 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 f5c87207d..7dcd2a2f8 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 c239a2689..67402182b 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 f2720bb7f..cf14f14a5 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 000000000..be94e1929 --- /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 90632aac2..8838888a6 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 74d96af92..bca8ee8bb 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 ea792b25e..f4a301497 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 07a246932..62e08b126 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 f8832104d..1f7fde074 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 98b15a040..c080c2e2b 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 16f615abb..9a6ad9a86 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 fd39a85f9..96f377353 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 e69de29bb..000000000 diff --git a/packages/payload/src/exports/shared.ts b/packages/payload/src/exports/shared.ts index c404336dc..0f6b2431b 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 ea94b55c1..61c24caa9 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 fff45efe6..49f110eb0 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 000000000..4d62a088c --- /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 000000000..ba2d3ee6f --- /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 000000000..2453276b5 --- /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 000000000..a2f35de73 --- /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 7e9ad5ba0..0824be02e 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 f61ca5f27..8bfa199bb 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 725a6849a..bf9b4d802 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 60c87ee14..d92cb0569 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 643f6dd8d..af7c4f25f 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 ace7b7b19..717acbe63 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 e4ef1cf72..5b3cc213c 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 140d85406..c6235daf4 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 7b6a62e01..56b59453f 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 d66a3bf5d..9aa208150 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 c8476df60..f926cf8fe 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 e55801fad..5aba5fa6a 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 bede93231..49248629b 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 fd0208193..06ba68fcf 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 e6a89079e..8a17f5be6 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 58eb27c24..6b4966041 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 0f20ccdc4..c2bc56a71 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 271fef844..0ee1aaf47 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 8d0d3a29a..4ae82303f 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 258fbddb0..4191ce53f 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 1f1b4bb63..aa2dcb886 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 fbfd8521e..6d13bbcc3 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 911ab9dbb..2afcd0dd5 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 1fab28b04..215035613 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 b37990f5e..6a2e923c1 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 e38853395..025c3ad04 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 73dbb670e..a67690df6 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 e7cbd2845..48b383a95 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 ef8514a48..865f227a8 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 ca880f71e..d36fec3f4 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 be7f527bb..64c87c304 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 1cae34446..d03110008 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 067c08607..ed6cee95f 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 81551dcd1..cc716047b 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 701e9e580..009892e99 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 05586481b..70173f55b 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 1e2f95fbe..56a918979 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 cedd1b40d..57690d2f0 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 71d63e61d..10def8868 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 4d0bccec2..5a460b223 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 be386f234..2dfb06f2a 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 a194d629e..d310aa1b8 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 643e12eeb..e03cf154c 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 59e5d24fd..a53f4e966 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 a7fc4451d..fc68d6487 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 0795660ee..e2cf8f33a 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 2e94640cf..dee572ab6 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 688ddd7ae..7f7e3dc00 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 000000000..e73be0f35 --- /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 000000000..4e1be0e46 --- /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 a369793ff..0f3740a96 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 2deb78707..40dbed24e 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 000000000..a3ae40fca --- /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 000000000..cafc1d041 --- /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 3e7415170..27e6124a2 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 73f86928d..7d37ca115 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 3b946f806..f370546f3 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 80e9085ca..e7e5d310a 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 b29f07754..f9f4d4f4e 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 000000000..943ee639c --- /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 000000000..bd3e89052 --- /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 000000000..ff3e14c95 --- /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 000000000..00361be73 --- /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 6ffb7e525..afa2e41ec 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 000000000..d5d0cae8f --- /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 000000000..02ff5f808 --- /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 000000000..efd14ca6a --- /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 000000000..d7c875aee --- /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 000000000..37f31f036 --- /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 000000000..78570d667 --- /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 000000000..2376aad43 --- /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 000000000..564cbe82b --- /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 8219f994c..1c07f3a64 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 76795c6c1..a6588961c 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 644ee3921..20ca8ed2e 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 d9b4b21bd..7bf6ed356 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 1d1a53d72..c36770f88 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 a5268a3fd..cfde4aac6 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 eb102cabf..93c7b23c6 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 9bea6bf69..620bccb9b 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 000000000..f0156ed8c --- /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 000000000..23f5c168c --- /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 d282fdfad..6babc9b43 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 accaac5c4..2da537ebe 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 99c732889..9cd564575 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 93d9ddf0e..ad9dd0669 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 accd66a90..979b960a1 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 f0fe18960..f405bfea7 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 e0061a7e2..995b08087 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 4fefe9bb9..e962df1c7 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 bdd89ab7b..de1211278 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 916ce7836..b4273394c 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 52c9cec4e..3bb873e73 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 3cd66cca5..1b593f495 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 9ef3a6d15..169967297 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 375d25566..26e51ac81 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 9dd793865..78a054cb3 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 405a12348..cd5b2c2a5 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 4aeddcb2c..796a21b3c 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 ef3d48d69..245709e55 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 72573dc97..d65b67f98 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 935c80cd6..c5261ae94 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 8a605cf6c..b6babe2b1 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 6bb030183..f5e830de4 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 3cc9dcd83..d8f6e9e6f 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 a9664e58c..237792075 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 353319518..5598e6683 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 442ddea1d..eb57bf242 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 000000000..a91d961b8 --- /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 79bf3500c..0a6de84d0 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 96416b26a..fa0301ed3 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 02eb7a89f..18f2c757a 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 7fe7f5e6c..136bea72e 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 0fe077fb5..1967cb3e1 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 5c0bc9c4b..560569104 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 000000000..0a7f578f5 --- /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 000000000..227969b83 --- /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 000000000..cce01755f --- /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 000000000..7bd8a3659 --- /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 000000000..2d13acb81 --- /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 000000000..bf0e4d4e0 --- /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 000000000..30c19de3d --- /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 000000000..d7ebe5c4d --- /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 000000000..ad5ff3a66 --- /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 000000000..2e9bf1b16 --- /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 000000000..e9c5db1f1 --- /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 000000000..258c2b070 --- /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 000000000..3fba8f4f6 --- /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 000000000..20fbd0a7b --- /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 000000000..ef6cee916 --- /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 000000000..38f116f67 --- /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 000000000..85c6ec36f --- /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 000000000..b34cc7afb --- /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 000000000..3c43903cf --- /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 730f7b489..805370aa1 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 c9793d25c..daa36c721 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"],