diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index a37d8526b4..cb7083602b 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -12,10 +12,10 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma **Example uses:** @@ -26,28 +26,28 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma ### Config -| Option | Description | -| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | -| **`relationTo`** \* | Provide one or many collection `slug`s to be able to assign relationships to. | -| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). | -| **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. | -| **`minRows`** | A number for the fewest allowed items during validation when a value is present. Used with `hasMany`. | -| **`maxRows`** | A number for the most allowed items during validation when a value is present. Used with `hasMany`. | -| **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) | -| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. | -| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) | +| Option | Description | +|---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`relationTo`** \* | Provide one or many collection `slug`s to be able to assign relationships to. | +| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). | +| **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. | +| **`minRows`** | A number for the fewest allowed items during validation when a value is present. Used with `hasMany`. | +| **`maxRows`** | A number for the most allowed items during validation when a value is present. Used with `hasMany`. | +| **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) | +| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. | +| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) | | **`index`** | Build a an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | -| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. | -| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) | -| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) | -| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | -| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | -| **`required`** | Require this field to have a value. | -| **`admin`** | Admin-specific configuration. See the [default field admin config](/docs/fields/overview#admin-config) for more details. | -| **`custom`** | Extension point for adding custom data (e.g. for plugins) | +| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. | +| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) | +| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) | +| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. | +| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | +| **`required`** | Require this field to have a value. | +| **`admin`** | Admin-specific configuration. See the [default field admin config](/docs/fields/overview#admin-config) for more details. | +| **`custom`** | Extension point for adding custom data (e.g. for plugins) | _\* An asterisk denotes that a property is required._ @@ -60,47 +60,62 @@ _\* An asterisk denotes that a property is required._ ### Admin config -In addition to the default [field admin config](/docs/fields/overview#admin-config), the Relationship field type also allows for the following admin-specific properties: +In addition to the default [field admin config](/docs/fields/overview#admin-config), the Relationship field type also +allows for the following admin-specific properties: **`isSortable`** -Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop (only works when `hasMany` is set to `true`). +Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop (only works when `hasMany` +is set to `true`). **`allowCreate`** -Set to `false` if you'd like to disable the ability to create new documents from within the relationship field (hides the "Add new" button in the admin UI). +Set to `false` if you'd like to disable the ability to create new documents from within the relationship field (hides +the "Add new" button in the admin UI). **`sortOptions`** -The `sortOptions` property allows you to define a default sorting order for the options within a Relationship field's dropdown. This can be particularly useful for ensuring that the most relevant options are presented first to the user. +The `sortOptions` property allows you to define a default sorting order for the options within a Relationship field's +dropdown. This can be particularly useful for ensuring that the most relevant options are presented first to the user. You can specify `sortOptions` in two ways: **As a string:** -Provide a string to define a global default sort field for all relationship field dropdowns across different collections. You can prefix the field name with a minus symbol ("-") to sort in descending order. +Provide a string to define a global default sort field for all relationship field dropdowns across different +collections. You can prefix the field name with a minus symbol ("-") to sort in descending order. Example: ```ts sortOptions: 'fieldName', ``` + This configuration will sort all relationship field dropdowns by `"fieldName"` in ascending order. **As an object :** -Specify an object where keys are collection slugs and values are strings representing the field names to sort by. This allows for different sorting fields for each collection's relationship dropdown. +Specify an object where keys are collection slugs and values are strings representing the field names to sort by. This +allows for different sorting fields for each collection's relationship dropdown. Example: ```ts sortOptions: { - "pages": "fieldName1", - "posts": "-fieldName2", - "categories": "fieldName3" + "pages" +: + "fieldName1", + "posts" +: + "-fieldName2", + "categories" +: + "fieldName3" } ``` + In this configuration: + - Dropdowns related to `pages` will be sorted by `"fieldName1"` in ascending order. - Dropdowns for `posts` will use `"fieldName2"` for sorting in descending order (noted by the "-" prefix). - Dropdowns associated with `categories` will sort based on `"fieldName3"` in ascending order. @@ -109,12 +124,15 @@ Note: If `sortOptions` is not defined, the default sorting behavior of the Relat ### Filtering relationship options -Options can be dynamically limited by supplying a [query constraint](/docs/queries/overview), which will be used both for validating input and filtering available relationships in the UI. +Options can be dynamically limited by supplying a [query constraint](/docs/queries/overview), which will be used both +for validating input and filtering available relationships in the UI. -The `filterOptions` property can either be a `Where` query directly, or a function (synchronous or asynchronous) that returns one. When using a function, it will be called with an argument object containing the following properties: +The `filterOptions` property can either be a `Where` query, or a function returning `true` to not filter, `false` to +prevent all, or a `Where` query. When using a function, it will be +called with an argument object with the following properties: | Property | Description | -| ------------- | ------------------------------------------------------------------------------------ | +|---------------|--------------------------------------------------------------------------------------| | `relationTo` | The `relationTo` to filter against (as defined on the field) | | `data` | An object of the full collection or global document currently being edited | | `siblingData` | An object of the document data limited to fields within the same parent to the field | @@ -165,16 +183,21 @@ You can learn more about writing queries [here](/docs/queries/overview). ### How the data is saved -Given the variety of options possible within the `relationship` field type, the shape of the data needed for creating and updating these fields can vary. The following sections will describe the variety of data shapes that can arise from this field. +Given the variety of options possible within the `relationship` field type, the shape of the data needed for creating +and updating these fields can vary. The following sections will describe the variety of data shapes that can arise from +this field. #### Has One -The most simple pattern of a relationship is to use `hasMany: false` with a `relationTo` that allows for only one type of collection. +The most simple pattern of a relationship is to use `hasMany: false` with a `relationTo` that allows for only one type +of collection. ```ts { slug: 'example-collection', - fields: [ + fields +: + [ { name: 'owner', // required type: 'relationship', // required @@ -200,12 +223,15 @@ When querying documents in this collection via REST API, you could query as foll #### Has One - Polymorphic -Also known as **dynamic references**, in this configuration, the `relationTo` field is an array of Collection slugs that tells Payload which Collections are valid to reference. +Also known as **dynamic references**, in this configuration, the `relationTo` field is an array of Collection slugs that +tells Payload which Collections are valid to reference. ```ts { slug: 'example-collection', - fields: [ + fields +: + [ { name: 'owner', // required type: 'relationship', // required @@ -244,7 +270,9 @@ The `hasMany` tells Payload that there may be more than one collection saved to ```ts { slug: 'example-collection', - fields: [ + fields +: + [ { name: 'owners', // required type: 'relationship', // required @@ -259,7 +287,10 @@ To save the to `hasMany` relationship field we need to send an array of IDs: ```json { - "owners": ["6031ac9e1289176380734024", "602c3c327b811235943ee12b"] + "owners": [ + "6031ac9e1289176380734024", + "602c3c327b811235943ee12b" + ] } ``` @@ -272,7 +303,9 @@ When querying documents, the format does not change for arrays: ```ts { slug: 'example-collection', - fields: [ + fields +: + [ { name: 'owners', // required type: 'relationship', // required @@ -284,7 +317,8 @@ When querying documents, the format does not change for arrays: } ``` -Relationship fields with `hasMany` set to more than one kind of collections save their data as an array of objects—each containing the Collection `slug` as the `relationTo` value, and the related document `id` for the `value`: +Relationship fields with `hasMany` set to more than one kind of collections save their data as an array of objects—each +containing the Collection `slug` as the `relationTo` value, and the related document `id` for the `value`: ```json { @@ -305,12 +339,14 @@ Querying is done in the same way as the earlier Polymorphic example: `?where[owners.value][equals]=6031ac9e1289176380734024`. - #### Querying and Filtering Polymorphic Relationships -Polymorphic and non-polymorphic relationships must be queried differently because of how the related data is stored and may be inconsistent across different collections. Because of this, filtering polymorphic relationship fields from the Collection List admin UI is limited to the `id` value. +Polymorphic and non-polymorphic relationships must be queried differently because of how the related data is stored and +may be inconsistent across different collections. Because of this, filtering polymorphic relationship fields from the +Collection List admin UI is limited to the `id` value. -For a polymorphic relationship, the response will always be an array of objects. Each object will contain the `relationTo` and `value` properties. +For a polymorphic relationship, the response will always be an array of objects. Each object will contain +the `relationTo` and `value` properties. The data can be queried by the related document ID: diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index 0beef2fe8f..8149ae159d 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -20,10 +20,10 @@ keywords: upload, images media, fields, config, configuration, documentation, Co **Example uses:** @@ -34,25 +34,25 @@ keywords: upload, images media, fields, config, configuration, documentation, Co ### Config -| Option | Description | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | -| **`*relationTo`** \* | Provide a single collection `slug` to allow this field to accept a relation to. Note: the related collection must be configured to support Uploads. | -| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-upload-options). | -| **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) | -| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. | -| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | -| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) | +| Option | Description | +|----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | +| **`*relationTo`** \* | Provide a single collection `slug` to allow this field to accept a relation to. Note: the related collection must be configured to support Uploads. | +| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-upload-options). | +| **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) | +| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. | +| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. | +| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) | | **`index`** | Build an [index](/docs/database/overview) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. | -| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. | -| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) | -| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) | -| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. | -| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | -| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | -| **`required`** | Require this field to have a value. | -| **`admin`** | Admin-specific configuration. See the [default field admin config](/docs/fields/overview#admin-config) for more details. | -| **`custom`** | Extension point for adding custom data (e.g. for plugins) | +| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. | +| **`hooks`** | Provide field-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) | +| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) | +| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API or the Admin panel. | +| **`defaultValue`** | Provide data to be used for this field's default value. [More](/docs/fields/overview#default-values) | +| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. | +| **`required`** | Require this field to have a value. | +| **`admin`** | Admin-specific configuration. See the [default field admin config](/docs/fields/overview#admin-config) for more details. | +| **`custom`** | Extension point for adding custom data (e.g. for plugins) | _\* An asterisk denotes that a property is required._ @@ -78,12 +78,15 @@ export const ExampleCollection: CollectionConfig = { ### Filtering upload options -Options can be dynamically limited by supplying a [query constraint](/docs/queries/overview), which will be used both for validating input and filtering available uploads in the UI. +Options can be dynamically limited by supplying a [query constraint](/docs/queries/overview), which will be used both +for validating input and filtering available uploads in the UI. -The `filterOptions` property can either be a `Where` query directly, or a function that returns one. When using a function, it will be called with an argument object with the following properties: +The `filterOptions` property can either be a `Where` query, or a function returning `true` to not filter, `false` to +prevent all, or a `Where` query. When using a function, it will be +called with an argument object with the following properties: | Property | Description | -| ------------- | ------------------------------------------------------------------------------------ | +|---------------|--------------------------------------------------------------------------------------| | `relationTo` | The `relationTo` to filter against (as defined on the field) | | `data` | An object of the full collection or global document currently being edited | | `siblingData` | An object of the document data limited to fields within the same parent to the field | diff --git a/packages/payload/src/admin/components/elements/ListDrawer/DrawerContent.tsx b/packages/payload/src/admin/components/elements/ListDrawer/DrawerContent.tsx index a7d77188ce..a066f2323b 100644 --- a/packages/payload/src/admin/components/elements/ListDrawer/DrawerContent.tsx +++ b/packages/payload/src/admin/components/elements/ListDrawer/DrawerContent.tsx @@ -144,9 +144,10 @@ export const ListDrawerContent: React.FC = ({ } = {} let copyOfWhere = { ...(where || {}) } + const filterOption = filterOptions?.[slug] - if (filterOptions) { - copyOfWhere = hoistQueryParamsToAnd(copyOfWhere, filterOptions[slug]) + if (filterOptions && typeof filterOption !== 'boolean') { + copyOfWhere = hoistQueryParamsToAnd(copyOfWhere, filterOption) } if (search) { diff --git a/packages/payload/src/admin/components/forms/field-types/Relationship/index.tsx b/packages/payload/src/admin/components/forms/field-types/Relationship/index.tsx index 99b9859589..e029a7aa7e 100644 --- a/packages/payload/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/packages/payload/src/admin/components/forms/field-types/Relationship/index.tsx @@ -130,6 +130,7 @@ const Relationship: React.FC = (props) => { if (!errorLoading) { relationsToFetch.reduce(async (priorRelation, relation) => { + const relationFilterOption = filterOptionsResult?.[relation] let lastLoadedPageToUse if (search !== searchArg) { lastLoadedPageToUse = 1 @@ -138,6 +139,11 @@ const Relationship: React.FC = (props) => { } await priorRelation + if (relationFilterOption === false) { + setLastFullyLoadedRelation(relations.indexOf(relation)) + return Promise.resolve() + } + if (resultsFetched < 10) { const collection = collections.find((coll) => coll.slug === relation) let fieldToSearch = collection?.defaultSort || collection?.admin?.useAsTitle || 'id' @@ -177,8 +183,8 @@ const Relationship: React.FC = (props) => { }) } - if (filterOptionsResult?.[relation]) { - query.where.and.push(filterOptionsResult[relation]) + if (relationFilterOption && typeof relationFilterOption !== 'boolean') { + query.where.and.push(relationFilterOption) } const response = await fetch(`${serverURL}${api}/${relation}?${qs.stringify(query)}`, { diff --git a/packages/payload/src/admin/components/forms/field-types/Relationship/types.ts b/packages/payload/src/admin/components/forms/field-types/Relationship/types.ts index 837c6f5dde..c903cf043e 100644 --- a/packages/payload/src/admin/components/forms/field-types/Relationship/types.ts +++ b/packages/payload/src/admin/components/forms/field-types/Relationship/types.ts @@ -61,5 +61,5 @@ export type GetResults = (args: { }) => Promise export type FilterOptionsResult = { - [relation: string]: Where + [relation: string]: Where | boolean } diff --git a/packages/payload/src/admin/components/forms/field-types/getFilterOptionsQuery.ts b/packages/payload/src/admin/components/forms/field-types/getFilterOptionsQuery.ts index c9b1b4fc8f..0d9e374025 100644 --- a/packages/payload/src/admin/components/forms/field-types/getFilterOptionsQuery.ts +++ b/packages/payload/src/admin/components/forms/field-types/getFilterOptionsQuery.ts @@ -15,6 +15,13 @@ export const getFilterOptionsQuery = async ( typeof filterOptions === 'function' ? await filterOptions({ ...options, relationTo: relation }) : filterOptions + if (query[relation] === true) { + query[relation] = {} + } + // this is an ugly way to prevent results from being returned + if (query[relation] === false) { + query[relation] = { id: { exists: false } } + } }), ) } diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts index b9eec3ba87..3c518a1613 100644 --- a/packages/payload/src/fields/config/types.ts +++ b/packages/payload/src/fields/config/types.ts @@ -89,7 +89,7 @@ export type FilterOptionsProps = { } export type FilterOptions = - | ((options: FilterOptionsProps) => Promise | Where) + | ((options: FilterOptionsProps) => Promise | Where | boolean) | Where | null diff --git a/packages/payload/src/fields/validations.ts b/packages/payload/src/fields/validations.ts index ea2cc8c24e..869aab31cb 100644 --- a/packages/payload/src/fields/validations.ts +++ b/packages/payload/src/fields/validations.ts @@ -253,12 +253,13 @@ const validateFilterOptions: Validate = async ( [collection: string]: (number | string)[] } = {} + const falseCollections: string[] = [] const collections = typeof relationTo === 'string' ? [relationTo] : relationTo const values = Array.isArray(value) ? value : [value] await Promise.all( collections.map(async (collection) => { - const optionFilter = + let optionFilter = typeof filterOptions === 'function' ? await filterOptions({ id, @@ -269,6 +270,10 @@ const validateFilterOptions: Validate = async ( }) : filterOptions + if (optionFilter === true) { + optionFilter = null + } + const valueIDs: (number | string)[] = [] values.forEach((val) => { @@ -288,6 +293,10 @@ const validateFilterOptions: Validate = async ( if (optionFilter) findWhere.and.push(optionFilter) + if (optionFilter === false) { + falseCollections.push(optionFilter) + } + const result = await payload.find({ collection, depth: 0, @@ -321,6 +330,10 @@ const validateFilterOptions: Validate = async ( requestedID = val.value } + if (falseCollections.find((slug) => relationTo === slug)) { + return true + } + return options[collection].indexOf(requestedID) === -1 }) diff --git a/packages/richtext-lexical/src/field/features/Link/drawer/baseFields.ts b/packages/richtext-lexical/src/field/features/Link/drawer/baseFields.ts index 941dd6c9d5..c50c3aeaf3 100644 --- a/packages/richtext-lexical/src/field/features/Link/drawer/baseFields.ts +++ b/packages/richtext-lexical/src/field/features/Link/drawer/baseFields.ts @@ -1,6 +1,6 @@ +import type { User } from 'payload/auth' import type { Config } from 'payload/config' -import type { Field } from 'payload/types' -import type { RadioField, TextField } from 'payload/types' +import type { Field, RadioField, TextField } from 'payload/types' import { extractTranslations } from 'payload/utilities' @@ -36,7 +36,12 @@ export const getBaseFields = ( .map(({ slug }) => slug) } else { enabledRelations = config.collections - .filter(({ admin: { enableRichTextLink } }) => enableRichTextLink) + .filter(({ admin: { enableRichTextLink, hidden } }) => { + if (typeof hidden !== 'function' && hidden) { + return false + } + return enableRichTextLink + }) .map(({ slug }) => slug) } @@ -107,6 +112,16 @@ export const getBaseFields = ( return fields?.linkType === 'internal' }, }, + // when admin.hidden is a function we need to dynamically call hidden with the user to know if the collection should be shown + filterOptions: + !enabledCollections && !disabledCollections + ? ({ relationTo, user }) => { + const hidden = config.collections.find(({ slug }) => slug === relationTo).admin.hidden + if (typeof hidden === 'function' && hidden({ user } as { user: User })) { + return false + } + } + : null, label: translations['fields:chooseDocumentToLink'], relationTo: enabledRelations, required: true, diff --git a/packages/richtext-lexical/src/field/features/Relationship/utils/EnabledRelationshipsCondition.tsx b/packages/richtext-lexical/src/field/features/Relationship/utils/EnabledRelationshipsCondition.tsx index a713228a85..6d77ee510a 100644 --- a/packages/richtext-lexical/src/field/features/Relationship/utils/EnabledRelationshipsCondition.tsx +++ b/packages/richtext-lexical/src/field/features/Relationship/utils/EnabledRelationshipsCondition.tsx @@ -1,16 +1,23 @@ +import type { User } from 'payload/auth' import type { SanitizedCollectionConfig } from 'payload/types' -import { useConfig } from 'payload/components/utilities' +import { useAuth, useConfig } from 'payload/components/utilities' import * as React from 'react' -type options = { uploads: boolean } +type options = { + uploads: boolean + user: User +} type FilteredCollectionsT = ( collections: SanitizedCollectionConfig[], options?: options, ) => SanitizedCollectionConfig[] const filterRichTextCollections: FilteredCollectionsT = (collections, options) => { - return collections.filter(({ admin: { enableRichTextRelationship }, upload }) => { + return collections.filter(({ admin: { enableRichTextRelationship, hidden }, upload }) => { + if (hidden === true || (typeof hidden === 'function' && hidden({ user: options.user }))) { + return false + } if (options?.uploads) { return enableRichTextRelationship && Boolean(upload) === true } @@ -22,8 +29,9 @@ const filterRichTextCollections: FilteredCollectionsT = (collections, options) = export const EnabledRelationshipsCondition: React.FC = (props) => { const { children, uploads = false, ...rest } = props const { collections } = useConfig() + const { user } = useAuth() const [enabledCollectionSlugs] = React.useState(() => - filterRichTextCollections(collections, { uploads }).map(({ slug }) => slug), + filterRichTextCollections(collections, { uploads, user }).map(({ slug }) => slug), ) if (!enabledCollectionSlugs.length) { diff --git a/packages/richtext-slate/src/field/elements/EnabledRelationshipsCondition.tsx b/packages/richtext-slate/src/field/elements/EnabledRelationshipsCondition.tsx index 87e7c3ee91..f493fa862a 100644 --- a/packages/richtext-slate/src/field/elements/EnabledRelationshipsCondition.tsx +++ b/packages/richtext-slate/src/field/elements/EnabledRelationshipsCondition.tsx @@ -1,18 +1,25 @@ 'use client' +import type { User } from 'payload/auth' import type { SanitizedCollectionConfig } from 'payload/types' -import { useConfig } from 'payload/components/utilities' +import { useAuth, useConfig } from 'payload/components/utilities' import * as React from 'react' -type options = { uploads: boolean } +type options = { + uploads: boolean + user: User +} type FilteredCollectionsT = ( collections: SanitizedCollectionConfig[], options?: options, ) => SanitizedCollectionConfig[] const filterRichTextCollections: FilteredCollectionsT = (collections, options) => { - return collections.filter(({ admin: { enableRichTextRelationship }, upload }) => { + return collections.filter(({ admin: { enableRichTextRelationship, hidden }, upload }) => { + if (hidden === true || (typeof hidden === 'function' && hidden({ user: options.user }))) { + return false + } if (options?.uploads) { return enableRichTextRelationship && Boolean(upload) === true } @@ -24,8 +31,9 @@ const filterRichTextCollections: FilteredCollectionsT = (collections, options) = export const EnabledRelationshipsCondition: React.FC = (props) => { const { children, uploads = false, ...rest } = props const { collections } = useConfig() + const { user } = useAuth() const [enabledCollectionSlugs] = React.useState(() => - filterRichTextCollections(collections, { uploads }).map(({ slug }) => slug), + filterRichTextCollections(collections, { uploads, user }).map(({ slug }) => slug), ) if (!enabledCollectionSlugs.length) { diff --git a/packages/richtext-slate/src/field/elements/link/LinkDrawer/baseFields.ts b/packages/richtext-slate/src/field/elements/link/LinkDrawer/baseFields.ts index e864794ea0..8fefa1b7de 100644 --- a/packages/richtext-slate/src/field/elements/link/LinkDrawer/baseFields.ts +++ b/packages/richtext-slate/src/field/elements/link/LinkDrawer/baseFields.ts @@ -1,3 +1,4 @@ +import type { User } from 'payload/auth' import type { Config } from 'payload/config' import type { Field } from 'payload/types' @@ -57,9 +58,21 @@ export const getBaseFields = (config: Config): Field[] => [ return linkType === 'internal' }, }, + // when admin.hidden is a function we need to dynamically call hidden with the user to know if the collection should be shown + filterOptions: ({ relationTo, user }) => { + const hidden = config.collections.find(({ slug }) => slug === relationTo).admin.hidden + if (typeof hidden === 'function' && hidden({ user } as { user: User })) { + return false + } + }, label: translations['fields:chooseDocumentToLink'], relationTo: config.collections - .filter(({ admin: { enableRichTextLink } }) => enableRichTextLink) + .filter(({ admin: { enableRichTextLink, hidden } }) => { + if (typeof hidden !== 'function' && hidden) { + return false + } + return enableRichTextLink + }) .map(({ slug }) => slug), required: true, type: 'relationship', diff --git a/test/fields-relationship/collectionSlugs.ts b/test/fields-relationship/collectionSlugs.ts index 4ee93529a6..94ed552a0b 100644 --- a/test/fields-relationship/collectionSlugs.ts +++ b/test/fields-relationship/collectionSlugs.ts @@ -1,6 +1,8 @@ export const slug = 'fields-relationship' export const relationOneSlug = 'relation-one' +export const relationTrueFilterOptionSlug = 'relation-filter-true' +export const relationFalseFilterOptionSlug = 'relation-filter-false' export const relationTwoSlug = 'relation-two' export const relationRestrictedSlug = 'relation-restricted' export const relationWithTitleSlug = 'relation-with-title' diff --git a/test/fields-relationship/config.ts b/test/fields-relationship/config.ts index 6f3ca6e685..a6bc8d7a12 100644 --- a/test/fields-relationship/config.ts +++ b/test/fields-relationship/config.ts @@ -8,8 +8,10 @@ import { PrePopulateFieldUI } from './PrePopulateFieldUI' import { collection1Slug, collection2Slug, + relationFalseFilterOptionSlug, relationOneSlug, relationRestrictedSlug, + relationTrueFilterOptionSlug, relationTwoSlug, relationUpdatedExternallySlug, relationWithTitleSlug, @@ -32,6 +34,7 @@ export interface RelationOne { id: string name: string } + export type RelationTwo = RelationOne export type RelationRestricted = RelationOne export type RelationWithTitle = RelationOne @@ -46,7 +49,6 @@ const baseRelationshipFields: CollectionConfig['fields'] = [ export default buildConfigWithDefaults({ collections: [ { - slug, admin: { defaultColumns: [ 'id', @@ -58,41 +60,39 @@ export default buildConfigWithDefaults({ }, fields: [ { - type: 'relationship', name: 'relationship', relationTo: relationOneSlug, + type: 'relationship', }, { - type: 'relationship', name: 'relationshipHasMany', - relationTo: relationOneSlug, hasMany: true, + relationTo: relationOneSlug, + type: 'relationship', }, { - type: 'relationship', name: 'relationshipMultiple', relationTo: [relationOneSlug, relationTwoSlug], + type: 'relationship', }, { - type: 'relationship', name: 'relationshipHasManyMultiple', hasMany: true, relationTo: [relationOneSlug, relationTwoSlug], + type: 'relationship', }, { - type: 'relationship', name: 'relationshipRestricted', relationTo: relationRestrictedSlug, + type: 'relationship', }, { - type: 'relationship', name: 'relationshipWithTitle', relationTo: relationWithTitleSlug, + type: 'relationship', }, { - type: 'relationship', name: 'relationshipFiltered', - relationTo: relationOneSlug, filterOptions: (args: FilterOptionsProps) => { return { id: { @@ -100,11 +100,11 @@ export default buildConfigWithDefaults({ }, } }, + relationTo: relationOneSlug, + type: 'relationship', }, { - type: 'relationship', name: 'relationshipFilteredAsync', - relationTo: relationOneSlug, filterOptions: async (args: FilterOptionsProps) => { return { id: { @@ -112,57 +112,84 @@ export default buildConfigWithDefaults({ }, } }, + relationTo: relationOneSlug, + type: 'relationship', }, { - type: 'relationship', name: 'relationshipManyFiltered', - relationTo: [relationWithTitleSlug, relationOneSlug], - hasMany: true, filterOptions: ({ relationTo, siblingData }: any) => { if (relationTo === relationOneSlug) { return { name: { equals: 'include' } } } + if (relationTo === relationTrueFilterOptionSlug) { + return true + } + if (relationTo === relationFalseFilterOptionSlug) { + return false + } if (siblingData.filter) { return { name: { contains: siblingData.filter } } } return { and: [] } }, + hasMany: true, + relationTo: [ + relationWithTitleSlug, + relationFalseFilterOptionSlug, + relationTrueFilterOptionSlug, + relationOneSlug, + ], + type: 'relationship', }, { - type: 'text', name: 'filter', + type: 'text', }, { name: 'relationshipReadOnly', - type: 'relationship', - relationTo: relationOneSlug, admin: { readOnly: true, }, + relationTo: relationOneSlug, + type: 'relationship', }, ], + slug, }, { - slug: relationOneSlug, - fields: baseRelationshipFields, - }, - { - slug: relationTwoSlug, - fields: baseRelationshipFields, - }, - { - slug: relationRestrictedSlug, admin: { useAsTitle: 'name', }, fields: baseRelationshipFields, - access: { - read: () => false, - create: () => false, - }, + slug: relationFalseFilterOptionSlug, + }, + { + admin: { + useAsTitle: 'name', + }, + fields: baseRelationshipFields, + slug: relationTrueFilterOptionSlug, + }, + { + fields: baseRelationshipFields, + slug: relationOneSlug, + }, + { + fields: baseRelationshipFields, + slug: relationTwoSlug, + }, + { + access: { + create: () => false, + read: () => false, + }, + admin: { + useAsTitle: 'name', + }, + fields: baseRelationshipFields, + slug: relationRestrictedSlug, }, { - slug: relationWithTitleSlug, admin: { useAsTitle: 'meta.title', }, @@ -170,7 +197,6 @@ export default buildConfigWithDefaults({ ...baseRelationshipFields, { name: 'meta', - type: 'group', fields: [ { name: 'title', @@ -178,110 +204,112 @@ export default buildConfigWithDefaults({ type: 'text', }, ], + type: 'group', }, ], + slug: relationWithTitleSlug, }, { - slug: relationUpdatedExternallySlug, admin: { useAsTitle: 'name', }, fields: [ { - type: 'row', fields: [ { name: 'relationPrePopulate', - type: 'relationship', - relationTo: collection1Slug, admin: { width: '75%', }, + relationTo: collection1Slug, + type: 'relationship', }, { - type: 'ui', name: 'prePopulate', admin: { - width: '25%', components: { - Field: () => PrePopulateFieldUI({ path: 'relationPrePopulate', hasMany: false }), + Field: () => PrePopulateFieldUI({ hasMany: false, path: 'relationPrePopulate' }), }, + width: '25%', }, + type: 'ui', }, ], + type: 'row', }, { - type: 'row', fields: [ { name: 'relationHasMany', - type: 'relationship', - relationTo: collection1Slug, - hasMany: true, admin: { width: '75%', }, + hasMany: true, + relationTo: collection1Slug, + type: 'relationship', }, { - type: 'ui', name: 'prePopulateRelationHasMany', admin: { - width: '25%', components: { Field: () => - PrePopulateFieldUI({ path: 'relationHasMany', hasMultipleRelations: false }), + PrePopulateFieldUI({ hasMultipleRelations: false, path: 'relationHasMany' }), }, + width: '25%', }, + type: 'ui', }, ], + type: 'row', }, { - type: 'row', fields: [ { name: 'relationToManyHasMany', - type: 'relationship', - relationTo: [collection1Slug, collection2Slug], - hasMany: true, admin: { width: '75%', }, + hasMany: true, + relationTo: [collection1Slug, collection2Slug], + type: 'relationship', }, { - type: 'ui', name: 'prePopulateToMany', admin: { - width: '25%', components: { Field: () => PrePopulateFieldUI({ - path: 'relationToManyHasMany', hasMultipleRelations: true, + path: 'relationToManyHasMany', }), }, + width: '25%', }, + type: 'ui', }, ], + type: 'row', }, ], + slug: relationUpdatedExternallySlug, }, { + fields: [ + { + name: 'name', + type: 'text', + }, + ], slug: collection1Slug, - fields: [ - { - type: 'text', - name: 'name', - }, - ], }, { - slug: collection2Slug, fields: [ { - type: 'text', name: 'name', + type: 'text', }, ], + slug: collection2Slug, }, ], onInit: async (payload) => { @@ -358,11 +386,11 @@ export default buildConfigWithDefaults({ collection: slug, data: { relationship: relationOneDocId, - relationshipRestricted: restrictedDocId, relationshipHasManyMultiple: relationOneIDs.map((id) => ({ relationTo: relationOneSlug, value: id, })), + relationshipRestricted: restrictedDocId, }, }) }) @@ -374,10 +402,10 @@ export default buildConfigWithDefaults({ collection: slug, data: { relationship: relationOneDocId, - relationshipRestricted: restrictedDocId, relationshipHasMany: [relationOneID], relationshipHasManyMultiple: [{ relationTo: relationTwoSlug, value: relationTwoID }], relationshipReadOnly: relationOneID, + relationshipRestricted: restrictedDocId, }, }) }) diff --git a/test/fields-relationship/e2e.spec.ts b/test/fields-relationship/e2e.spec.ts index 2817f2a20a..b5df67b631 100644 --- a/test/fields-relationship/e2e.spec.ts +++ b/test/fields-relationship/e2e.spec.ts @@ -17,8 +17,10 @@ import { initPageConsoleErrorCatch, openDocControls, saveDocAndAssert } from '.. import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { initPayloadE2E } from '../helpers/configHelpers' import { + relationFalseFilterOptionSlug, relationOneSlug, relationRestrictedSlug, + relationTrueFilterOptionSlug, relationTwoSlug, relationUpdatedExternallySlug, relationWithTitleSlug, @@ -112,9 +114,9 @@ describe('fields - relationship', () => { data: { name: 'with-existing-relations', relationship: relationOneDoc.id, + relationshipReadOnly: relationOneDoc.id, relationshipRestricted: restrictedRelation.id, relationshipWithTitle: relationWithTitle.id, - relationshipReadOnly: relationOneDoc.id, }, })) as any }) @@ -322,6 +324,41 @@ describe('fields - relationship', () => { await expect(options).not.toContainText('exclude') }) + test('should not query for a relationship when filterOptions returns false', async () => { + await payload.create({ + collection: relationFalseFilterOptionSlug, + data: { + name: 'whatever', + }, + }) + + await page.goto(url.create) + + // select relationshipMany field that relies on siblingData field above + await page.locator('#field-relationshipManyFiltered .rs__control').click() + + const options = page.locator('#field-relationshipManyFiltered .rs__menu') + await expect(options).toContainText('Relation With Titles') + await expect(options).not.toContainText('whatever') + }) + + test('should show a relationship when filterOptions returns true', async () => { + await payload.create({ + collection: relationTrueFilterOptionSlug, + data: { + name: 'truth', + }, + }) + + await page.goto(url.create) + + // select relationshipMany field that relies on siblingData field above + await page.locator('#field-relationshipManyFiltered .rs__control').click() + + const options = page.locator('#field-relationshipManyFiltered .rs__menu') + await expect(options).toContainText('truth') + }) + test('should open document drawer from read-only relationships', async () => { await page.goto(url.edit(docWithExistingRelations.id)) @@ -492,6 +529,6 @@ async function clearCollectionDocs(collectionSlug: string): Promise { (doc) => doc.id, ) await mapAsync(ids, async (id) => { - await payload.delete({ collection: collectionSlug, id }) + await payload.delete({ id, collection: collectionSlug }) }) }