feat: prevent querying relationship when filterOptions returns false (#4392)

fix: hidden collections showing in lexical and slate relationships
feat: prevent querying relationship when filterOptions returns false
fix: hidden collections appear in richtext internal link options

Co-authored-by: Alessio Gravili <70709113+AlessioGr@users.noreply.github.com>
This commit is contained in:
Dan Ribbens
2023-12-15 12:43:43 -05:00
committed by GitHub
parent c49fd66922
commit c1bd338d0d
15 changed files with 336 additions and 159 deletions

View File

@@ -12,10 +12,10 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma
</Banner> </Banner>
<LightDarkImage <LightDarkImage
srcLight="https://payloadcms.com/images/docs/fields/relationship.png" srcLight="https://payloadcms.com/images/docs/fields/relationship.png"
srcDark="https://payloadcms.com/images/docs/fields/relationship-dark.png" srcDark="https://payloadcms.com/images/docs/fields/relationship-dark.png"
alt="Shows a relationship field in the Payload admin panel" alt="Shows a relationship field in the Payload admin panel"
caption="Admin panel screenshot of a Relationship field" caption="Admin panel screenshot of a Relationship field"
/> />
**Example uses:** **Example uses:**
@@ -26,28 +26,28 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma
### Config ### Config
| Option | Description | | Option | Description |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |---------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | | **`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. | | **`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). | | **`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. | | **`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`. | | **`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`. | | **`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) | | **`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. | | **`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. | | **`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) | | **`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. | | **`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. | | **`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) | | **`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) | | **`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. | | **`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) | | **`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. | | **`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. | | **`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. | | **`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) | | **`custom`** | Extension point for adding custom data (e.g. for plugins) |
_\* An asterisk denotes that a property is required._ _\* An asterisk denotes that a property is required._
@@ -60,47 +60,62 @@ _\* An asterisk denotes that a property is required._
### Admin config ### 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`** **`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`** **`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`** **`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: You can specify `sortOptions` in two ways:
**As a string:** **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: Example:
```ts ```ts
sortOptions: 'fieldName', sortOptions: 'fieldName',
``` ```
This configuration will sort all relationship field dropdowns by `"fieldName"` in ascending order. This configuration will sort all relationship field dropdowns by `"fieldName"` in ascending order.
**As an object :** **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: Example:
```ts ```ts
sortOptions: { sortOptions: {
"pages": "fieldName1", "pages"
"posts": "-fieldName2", :
"categories": "fieldName3" "fieldName1",
"posts"
:
"-fieldName2",
"categories"
:
"fieldName3"
} }
``` ```
In this configuration: In this configuration:
- Dropdowns related to `pages` will be sorted by `"fieldName1"` in ascending order. - 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 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. - 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 ### 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 | | Property | Description |
| ------------- | ------------------------------------------------------------------------------------ | |---------------|--------------------------------------------------------------------------------------|
| `relationTo` | The `relationTo` to filter against (as defined on the field) | | `relationTo` | The `relationTo` to filter against (as defined on the field) |
| `data` | An object of the full collection or global document currently being edited | | `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 | | `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 ### 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 #### 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 ```ts
{ {
slug: 'example-collection', slug: 'example-collection',
fields: [ fields
:
[
{ {
name: 'owner', // required name: 'owner', // required
type: 'relationship', // 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 #### 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 ```ts
{ {
slug: 'example-collection', slug: 'example-collection',
fields: [ fields
:
[
{ {
name: 'owner', // required name: 'owner', // required
type: 'relationship', // required type: 'relationship', // required
@@ -244,7 +270,9 @@ The `hasMany` tells Payload that there may be more than one collection saved to
```ts ```ts
{ {
slug: 'example-collection', slug: 'example-collection',
fields: [ fields
:
[
{ {
name: 'owners', // required name: 'owners', // required
type: 'relationship', // required type: 'relationship', // required
@@ -259,7 +287,10 @@ To save the to `hasMany` relationship field we need to send an array of IDs:
```json ```json
{ {
"owners": ["6031ac9e1289176380734024", "602c3c327b811235943ee12b"] "owners": [
"6031ac9e1289176380734024",
"602c3c327b811235943ee12b"
]
} }
``` ```
@@ -272,7 +303,9 @@ When querying documents, the format does not change for arrays:
```ts ```ts
{ {
slug: 'example-collection', slug: 'example-collection',
fields: [ fields
:
[
{ {
name: 'owners', // required name: 'owners', // required
type: 'relationship', // 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 ```json
{ {
@@ -305,12 +339,14 @@ Querying is done in the same way as the earlier Polymorphic example:
`?where[owners.value][equals]=6031ac9e1289176380734024`. `?where[owners.value][equals]=6031ac9e1289176380734024`.
#### Querying and Filtering Polymorphic Relationships #### 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: The data can be queried by the related document ID:

View File

@@ -20,10 +20,10 @@ keywords: upload, images media, fields, config, configuration, documentation, Co
</Banner> </Banner>
<LightDarkImage <LightDarkImage
srcLight="https://payloadcms.com/images/docs/fields/upload.png" srcLight="https://payloadcms.com/images/docs/fields/upload.png"
srcDark="https://payloadcms.com/images/docs/fields/upload-dark.png" srcDark="https://payloadcms.com/images/docs/fields/upload-dark.png"
alt="Shows an upload field in the Payload admin panel" alt="Shows an upload field in the Payload admin panel"
caption="Admin panel screenshot of an Upload field" caption="Admin panel screenshot of an Upload field"
/> />
**Example uses:** **Example uses:**
@@ -34,25 +34,25 @@ keywords: upload, images media, fields, config, configuration, documentation, Co
### Config ### Config
| Option | Description | | Option | Description |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | |----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) | | **`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. <strong>Note: the related collection must be configured to support Uploads.</strong> | | **`*relationTo`** \* | Provide a single collection `slug` to allow this field to accept a relation to. <strong>Note: the related collection must be configured to support Uploads.</strong> |
| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-upload-options). | | **`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) | | **`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. | | **`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. | | **`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) | | **`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. | | **`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. | | **`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) | | **`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) | | **`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. | | **`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) | | **`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. | | **`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. | | **`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. | | **`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) | | **`custom`** | Extension point for adding custom data (e.g. for plugins) |
_\* An asterisk denotes that a property is required._ _\* An asterisk denotes that a property is required._
@@ -78,12 +78,15 @@ export const ExampleCollection: CollectionConfig = {
### Filtering upload options ### 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 | | Property | Description |
| ------------- | ------------------------------------------------------------------------------------ | |---------------|--------------------------------------------------------------------------------------|
| `relationTo` | The `relationTo` to filter against (as defined on the field) | | `relationTo` | The `relationTo` to filter against (as defined on the field) |
| `data` | An object of the full collection or global document currently being edited | | `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 | | `siblingData` | An object of the document data limited to fields within the same parent to the field |

View File

@@ -144,9 +144,10 @@ export const ListDrawerContent: React.FC<ListDrawerProps> = ({
} = {} } = {}
let copyOfWhere = { ...(where || {}) } let copyOfWhere = { ...(where || {}) }
const filterOption = filterOptions?.[slug]
if (filterOptions) { if (filterOptions && typeof filterOption !== 'boolean') {
copyOfWhere = hoistQueryParamsToAnd(copyOfWhere, filterOptions[slug]) copyOfWhere = hoistQueryParamsToAnd(copyOfWhere, filterOption)
} }
if (search) { if (search) {

View File

@@ -130,6 +130,7 @@ const Relationship: React.FC<Props> = (props) => {
if (!errorLoading) { if (!errorLoading) {
relationsToFetch.reduce(async (priorRelation, relation) => { relationsToFetch.reduce(async (priorRelation, relation) => {
const relationFilterOption = filterOptionsResult?.[relation]
let lastLoadedPageToUse let lastLoadedPageToUse
if (search !== searchArg) { if (search !== searchArg) {
lastLoadedPageToUse = 1 lastLoadedPageToUse = 1
@@ -138,6 +139,11 @@ const Relationship: React.FC<Props> = (props) => {
} }
await priorRelation await priorRelation
if (relationFilterOption === false) {
setLastFullyLoadedRelation(relations.indexOf(relation))
return Promise.resolve()
}
if (resultsFetched < 10) { if (resultsFetched < 10) {
const collection = collections.find((coll) => coll.slug === relation) const collection = collections.find((coll) => coll.slug === relation)
let fieldToSearch = collection?.defaultSort || collection?.admin?.useAsTitle || 'id' let fieldToSearch = collection?.defaultSort || collection?.admin?.useAsTitle || 'id'
@@ -177,8 +183,8 @@ const Relationship: React.FC<Props> = (props) => {
}) })
} }
if (filterOptionsResult?.[relation]) { if (relationFilterOption && typeof relationFilterOption !== 'boolean') {
query.where.and.push(filterOptionsResult[relation]) query.where.and.push(relationFilterOption)
} }
const response = await fetch(`${serverURL}${api}/${relation}?${qs.stringify(query)}`, { const response = await fetch(`${serverURL}${api}/${relation}?${qs.stringify(query)}`, {

View File

@@ -61,5 +61,5 @@ export type GetResults = (args: {
}) => Promise<void> }) => Promise<void>
export type FilterOptionsResult = { export type FilterOptionsResult = {
[relation: string]: Where [relation: string]: Where | boolean
} }

View File

@@ -15,6 +15,13 @@ export const getFilterOptionsQuery = async (
typeof filterOptions === 'function' typeof filterOptions === 'function'
? await filterOptions({ ...options, relationTo: relation }) ? await filterOptions({ ...options, relationTo: relation })
: filterOptions : 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 } }
}
}), }),
) )
} }

View File

@@ -89,7 +89,7 @@ export type FilterOptionsProps<T = any> = {
} }
export type FilterOptions<T = any> = export type FilterOptions<T = any> =
| ((options: FilterOptionsProps<T>) => Promise<Where> | Where) | ((options: FilterOptionsProps<T>) => Promise<Where | boolean> | Where | boolean)
| Where | Where
| null | null

View File

@@ -253,12 +253,13 @@ const validateFilterOptions: Validate = async (
[collection: string]: (number | string)[] [collection: string]: (number | string)[]
} = {} } = {}
const falseCollections: string[] = []
const collections = typeof relationTo === 'string' ? [relationTo] : relationTo const collections = typeof relationTo === 'string' ? [relationTo] : relationTo
const values = Array.isArray(value) ? value : [value] const values = Array.isArray(value) ? value : [value]
await Promise.all( await Promise.all(
collections.map(async (collection) => { collections.map(async (collection) => {
const optionFilter = let optionFilter =
typeof filterOptions === 'function' typeof filterOptions === 'function'
? await filterOptions({ ? await filterOptions({
id, id,
@@ -269,6 +270,10 @@ const validateFilterOptions: Validate = async (
}) })
: filterOptions : filterOptions
if (optionFilter === true) {
optionFilter = null
}
const valueIDs: (number | string)[] = [] const valueIDs: (number | string)[] = []
values.forEach((val) => { values.forEach((val) => {
@@ -288,6 +293,10 @@ const validateFilterOptions: Validate = async (
if (optionFilter) findWhere.and.push(optionFilter) if (optionFilter) findWhere.and.push(optionFilter)
if (optionFilter === false) {
falseCollections.push(optionFilter)
}
const result = await payload.find({ const result = await payload.find({
collection, collection,
depth: 0, depth: 0,
@@ -321,6 +330,10 @@ const validateFilterOptions: Validate = async (
requestedID = val.value requestedID = val.value
} }
if (falseCollections.find((slug) => relationTo === slug)) {
return true
}
return options[collection].indexOf(requestedID) === -1 return options[collection].indexOf(requestedID) === -1
}) })

View File

@@ -1,6 +1,6 @@
import type { User } from 'payload/auth'
import type { Config } from 'payload/config' import type { Config } from 'payload/config'
import type { Field } from 'payload/types' import type { Field, RadioField, TextField } from 'payload/types'
import type { RadioField, TextField } from 'payload/types'
import { extractTranslations } from 'payload/utilities' import { extractTranslations } from 'payload/utilities'
@@ -36,7 +36,12 @@ export const getBaseFields = (
.map(({ slug }) => slug) .map(({ slug }) => slug)
} else { } else {
enabledRelations = config.collections enabledRelations = config.collections
.filter(({ admin: { enableRichTextLink } }) => enableRichTextLink) .filter(({ admin: { enableRichTextLink, hidden } }) => {
if (typeof hidden !== 'function' && hidden) {
return false
}
return enableRichTextLink
})
.map(({ slug }) => slug) .map(({ slug }) => slug)
} }
@@ -107,6 +112,16 @@ export const getBaseFields = (
return fields?.linkType === 'internal' 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'], label: translations['fields:chooseDocumentToLink'],
relationTo: enabledRelations, relationTo: enabledRelations,
required: true, required: true,

View File

@@ -1,16 +1,23 @@
import type { User } from 'payload/auth'
import type { SanitizedCollectionConfig } from 'payload/types' import type { SanitizedCollectionConfig } from 'payload/types'
import { useConfig } from 'payload/components/utilities' import { useAuth, useConfig } from 'payload/components/utilities'
import * as React from 'react' import * as React from 'react'
type options = { uploads: boolean } type options = {
uploads: boolean
user: User
}
type FilteredCollectionsT = ( type FilteredCollectionsT = (
collections: SanitizedCollectionConfig[], collections: SanitizedCollectionConfig[],
options?: options, options?: options,
) => SanitizedCollectionConfig[] ) => SanitizedCollectionConfig[]
const filterRichTextCollections: FilteredCollectionsT = (collections, options) => { 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) { if (options?.uploads) {
return enableRichTextRelationship && Boolean(upload) === true return enableRichTextRelationship && Boolean(upload) === true
} }
@@ -22,8 +29,9 @@ const filterRichTextCollections: FilteredCollectionsT = (collections, options) =
export const EnabledRelationshipsCondition: React.FC<any> = (props) => { export const EnabledRelationshipsCondition: React.FC<any> = (props) => {
const { children, uploads = false, ...rest } = props const { children, uploads = false, ...rest } = props
const { collections } = useConfig() const { collections } = useConfig()
const { user } = useAuth()
const [enabledCollectionSlugs] = React.useState(() => const [enabledCollectionSlugs] = React.useState(() =>
filterRichTextCollections(collections, { uploads }).map(({ slug }) => slug), filterRichTextCollections(collections, { uploads, user }).map(({ slug }) => slug),
) )
if (!enabledCollectionSlugs.length) { if (!enabledCollectionSlugs.length) {

View File

@@ -1,18 +1,25 @@
'use client' 'use client'
import type { User } from 'payload/auth'
import type { SanitizedCollectionConfig } from 'payload/types' import type { SanitizedCollectionConfig } from 'payload/types'
import { useConfig } from 'payload/components/utilities' import { useAuth, useConfig } from 'payload/components/utilities'
import * as React from 'react' import * as React from 'react'
type options = { uploads: boolean } type options = {
uploads: boolean
user: User
}
type FilteredCollectionsT = ( type FilteredCollectionsT = (
collections: SanitizedCollectionConfig[], collections: SanitizedCollectionConfig[],
options?: options, options?: options,
) => SanitizedCollectionConfig[] ) => SanitizedCollectionConfig[]
const filterRichTextCollections: FilteredCollectionsT = (collections, options) => { 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) { if (options?.uploads) {
return enableRichTextRelationship && Boolean(upload) === true return enableRichTextRelationship && Boolean(upload) === true
} }
@@ -24,8 +31,9 @@ const filterRichTextCollections: FilteredCollectionsT = (collections, options) =
export const EnabledRelationshipsCondition: React.FC<any> = (props) => { export const EnabledRelationshipsCondition: React.FC<any> = (props) => {
const { children, uploads = false, ...rest } = props const { children, uploads = false, ...rest } = props
const { collections } = useConfig() const { collections } = useConfig()
const { user } = useAuth()
const [enabledCollectionSlugs] = React.useState(() => const [enabledCollectionSlugs] = React.useState(() =>
filterRichTextCollections(collections, { uploads }).map(({ slug }) => slug), filterRichTextCollections(collections, { uploads, user }).map(({ slug }) => slug),
) )
if (!enabledCollectionSlugs.length) { if (!enabledCollectionSlugs.length) {

View File

@@ -1,3 +1,4 @@
import type { User } from 'payload/auth'
import type { Config } from 'payload/config' import type { Config } from 'payload/config'
import type { Field } from 'payload/types' import type { Field } from 'payload/types'
@@ -57,9 +58,21 @@ export const getBaseFields = (config: Config): Field[] => [
return linkType === 'internal' 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'], label: translations['fields:chooseDocumentToLink'],
relationTo: config.collections relationTo: config.collections
.filter(({ admin: { enableRichTextLink } }) => enableRichTextLink) .filter(({ admin: { enableRichTextLink, hidden } }) => {
if (typeof hidden !== 'function' && hidden) {
return false
}
return enableRichTextLink
})
.map(({ slug }) => slug), .map(({ slug }) => slug),
required: true, required: true,
type: 'relationship', type: 'relationship',

View File

@@ -1,6 +1,8 @@
export const slug = 'fields-relationship' export const slug = 'fields-relationship'
export const relationOneSlug = 'relation-one' export const relationOneSlug = 'relation-one'
export const relationTrueFilterOptionSlug = 'relation-filter-true'
export const relationFalseFilterOptionSlug = 'relation-filter-false'
export const relationTwoSlug = 'relation-two' export const relationTwoSlug = 'relation-two'
export const relationRestrictedSlug = 'relation-restricted' export const relationRestrictedSlug = 'relation-restricted'
export const relationWithTitleSlug = 'relation-with-title' export const relationWithTitleSlug = 'relation-with-title'

View File

@@ -8,8 +8,10 @@ import { PrePopulateFieldUI } from './PrePopulateFieldUI'
import { import {
collection1Slug, collection1Slug,
collection2Slug, collection2Slug,
relationFalseFilterOptionSlug,
relationOneSlug, relationOneSlug,
relationRestrictedSlug, relationRestrictedSlug,
relationTrueFilterOptionSlug,
relationTwoSlug, relationTwoSlug,
relationUpdatedExternallySlug, relationUpdatedExternallySlug,
relationWithTitleSlug, relationWithTitleSlug,
@@ -32,6 +34,7 @@ export interface RelationOne {
id: string id: string
name: string name: string
} }
export type RelationTwo = RelationOne export type RelationTwo = RelationOne
export type RelationRestricted = RelationOne export type RelationRestricted = RelationOne
export type RelationWithTitle = RelationOne export type RelationWithTitle = RelationOne
@@ -46,7 +49,6 @@ const baseRelationshipFields: CollectionConfig['fields'] = [
export default buildConfigWithDefaults({ export default buildConfigWithDefaults({
collections: [ collections: [
{ {
slug,
admin: { admin: {
defaultColumns: [ defaultColumns: [
'id', 'id',
@@ -58,41 +60,39 @@ export default buildConfigWithDefaults({
}, },
fields: [ fields: [
{ {
type: 'relationship',
name: 'relationship', name: 'relationship',
relationTo: relationOneSlug, relationTo: relationOneSlug,
type: 'relationship',
}, },
{ {
type: 'relationship',
name: 'relationshipHasMany', name: 'relationshipHasMany',
relationTo: relationOneSlug,
hasMany: true, hasMany: true,
relationTo: relationOneSlug,
type: 'relationship',
}, },
{ {
type: 'relationship',
name: 'relationshipMultiple', name: 'relationshipMultiple',
relationTo: [relationOneSlug, relationTwoSlug], relationTo: [relationOneSlug, relationTwoSlug],
type: 'relationship',
}, },
{ {
type: 'relationship',
name: 'relationshipHasManyMultiple', name: 'relationshipHasManyMultiple',
hasMany: true, hasMany: true,
relationTo: [relationOneSlug, relationTwoSlug], relationTo: [relationOneSlug, relationTwoSlug],
type: 'relationship',
}, },
{ {
type: 'relationship',
name: 'relationshipRestricted', name: 'relationshipRestricted',
relationTo: relationRestrictedSlug, relationTo: relationRestrictedSlug,
type: 'relationship',
}, },
{ {
type: 'relationship',
name: 'relationshipWithTitle', name: 'relationshipWithTitle',
relationTo: relationWithTitleSlug, relationTo: relationWithTitleSlug,
type: 'relationship',
}, },
{ {
type: 'relationship',
name: 'relationshipFiltered', name: 'relationshipFiltered',
relationTo: relationOneSlug,
filterOptions: (args: FilterOptionsProps<FieldsRelationship>) => { filterOptions: (args: FilterOptionsProps<FieldsRelationship>) => {
return { return {
id: { id: {
@@ -100,11 +100,11 @@ export default buildConfigWithDefaults({
}, },
} }
}, },
relationTo: relationOneSlug,
type: 'relationship',
}, },
{ {
type: 'relationship',
name: 'relationshipFilteredAsync', name: 'relationshipFilteredAsync',
relationTo: relationOneSlug,
filterOptions: async (args: FilterOptionsProps<FieldsRelationship>) => { filterOptions: async (args: FilterOptionsProps<FieldsRelationship>) => {
return { return {
id: { id: {
@@ -112,57 +112,84 @@ export default buildConfigWithDefaults({
}, },
} }
}, },
relationTo: relationOneSlug,
type: 'relationship',
}, },
{ {
type: 'relationship',
name: 'relationshipManyFiltered', name: 'relationshipManyFiltered',
relationTo: [relationWithTitleSlug, relationOneSlug],
hasMany: true,
filterOptions: ({ relationTo, siblingData }: any) => { filterOptions: ({ relationTo, siblingData }: any) => {
if (relationTo === relationOneSlug) { if (relationTo === relationOneSlug) {
return { name: { equals: 'include' } } return { name: { equals: 'include' } }
} }
if (relationTo === relationTrueFilterOptionSlug) {
return true
}
if (relationTo === relationFalseFilterOptionSlug) {
return false
}
if (siblingData.filter) { if (siblingData.filter) {
return { name: { contains: siblingData.filter } } return { name: { contains: siblingData.filter } }
} }
return { and: [] } return { and: [] }
}, },
hasMany: true,
relationTo: [
relationWithTitleSlug,
relationFalseFilterOptionSlug,
relationTrueFilterOptionSlug,
relationOneSlug,
],
type: 'relationship',
}, },
{ {
type: 'text',
name: 'filter', name: 'filter',
type: 'text',
}, },
{ {
name: 'relationshipReadOnly', name: 'relationshipReadOnly',
type: 'relationship',
relationTo: relationOneSlug,
admin: { admin: {
readOnly: true, readOnly: true,
}, },
relationTo: relationOneSlug,
type: 'relationship',
}, },
], ],
slug,
}, },
{ {
slug: relationOneSlug,
fields: baseRelationshipFields,
},
{
slug: relationTwoSlug,
fields: baseRelationshipFields,
},
{
slug: relationRestrictedSlug,
admin: { admin: {
useAsTitle: 'name', useAsTitle: 'name',
}, },
fields: baseRelationshipFields, fields: baseRelationshipFields,
access: { slug: relationFalseFilterOptionSlug,
read: () => false, },
create: () => false, {
}, 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: { admin: {
useAsTitle: 'meta.title', useAsTitle: 'meta.title',
}, },
@@ -170,7 +197,6 @@ export default buildConfigWithDefaults({
...baseRelationshipFields, ...baseRelationshipFields,
{ {
name: 'meta', name: 'meta',
type: 'group',
fields: [ fields: [
{ {
name: 'title', name: 'title',
@@ -178,110 +204,112 @@ export default buildConfigWithDefaults({
type: 'text', type: 'text',
}, },
], ],
type: 'group',
}, },
], ],
slug: relationWithTitleSlug,
}, },
{ {
slug: relationUpdatedExternallySlug,
admin: { admin: {
useAsTitle: 'name', useAsTitle: 'name',
}, },
fields: [ fields: [
{ {
type: 'row',
fields: [ fields: [
{ {
name: 'relationPrePopulate', name: 'relationPrePopulate',
type: 'relationship',
relationTo: collection1Slug,
admin: { admin: {
width: '75%', width: '75%',
}, },
relationTo: collection1Slug,
type: 'relationship',
}, },
{ {
type: 'ui',
name: 'prePopulate', name: 'prePopulate',
admin: { admin: {
width: '25%',
components: { components: {
Field: () => PrePopulateFieldUI({ path: 'relationPrePopulate', hasMany: false }), Field: () => PrePopulateFieldUI({ hasMany: false, path: 'relationPrePopulate' }),
}, },
width: '25%',
}, },
type: 'ui',
}, },
], ],
type: 'row',
}, },
{ {
type: 'row',
fields: [ fields: [
{ {
name: 'relationHasMany', name: 'relationHasMany',
type: 'relationship',
relationTo: collection1Slug,
hasMany: true,
admin: { admin: {
width: '75%', width: '75%',
}, },
hasMany: true,
relationTo: collection1Slug,
type: 'relationship',
}, },
{ {
type: 'ui',
name: 'prePopulateRelationHasMany', name: 'prePopulateRelationHasMany',
admin: { admin: {
width: '25%',
components: { components: {
Field: () => Field: () =>
PrePopulateFieldUI({ path: 'relationHasMany', hasMultipleRelations: false }), PrePopulateFieldUI({ hasMultipleRelations: false, path: 'relationHasMany' }),
}, },
width: '25%',
}, },
type: 'ui',
}, },
], ],
type: 'row',
}, },
{ {
type: 'row',
fields: [ fields: [
{ {
name: 'relationToManyHasMany', name: 'relationToManyHasMany',
type: 'relationship',
relationTo: [collection1Slug, collection2Slug],
hasMany: true,
admin: { admin: {
width: '75%', width: '75%',
}, },
hasMany: true,
relationTo: [collection1Slug, collection2Slug],
type: 'relationship',
}, },
{ {
type: 'ui',
name: 'prePopulateToMany', name: 'prePopulateToMany',
admin: { admin: {
width: '25%',
components: { components: {
Field: () => Field: () =>
PrePopulateFieldUI({ PrePopulateFieldUI({
path: 'relationToManyHasMany',
hasMultipleRelations: true, hasMultipleRelations: true,
path: 'relationToManyHasMany',
}), }),
}, },
width: '25%',
}, },
type: 'ui',
}, },
], ],
type: 'row',
}, },
], ],
slug: relationUpdatedExternallySlug,
}, },
{ {
fields: [
{
name: 'name',
type: 'text',
},
],
slug: collection1Slug, slug: collection1Slug,
fields: [
{
type: 'text',
name: 'name',
},
],
}, },
{ {
slug: collection2Slug,
fields: [ fields: [
{ {
type: 'text',
name: 'name', name: 'name',
type: 'text',
}, },
], ],
slug: collection2Slug,
}, },
], ],
onInit: async (payload) => { onInit: async (payload) => {
@@ -358,11 +386,11 @@ export default buildConfigWithDefaults({
collection: slug, collection: slug,
data: { data: {
relationship: relationOneDocId, relationship: relationOneDocId,
relationshipRestricted: restrictedDocId,
relationshipHasManyMultiple: relationOneIDs.map((id) => ({ relationshipHasManyMultiple: relationOneIDs.map((id) => ({
relationTo: relationOneSlug, relationTo: relationOneSlug,
value: id, value: id,
})), })),
relationshipRestricted: restrictedDocId,
}, },
}) })
}) })
@@ -374,10 +402,10 @@ export default buildConfigWithDefaults({
collection: slug, collection: slug,
data: { data: {
relationship: relationOneDocId, relationship: relationOneDocId,
relationshipRestricted: restrictedDocId,
relationshipHasMany: [relationOneID], relationshipHasMany: [relationOneID],
relationshipHasManyMultiple: [{ relationTo: relationTwoSlug, value: relationTwoID }], relationshipHasManyMultiple: [{ relationTo: relationTwoSlug, value: relationTwoID }],
relationshipReadOnly: relationOneID, relationshipReadOnly: relationOneID,
relationshipRestricted: restrictedDocId,
}, },
}) })
}) })

View File

@@ -17,8 +17,10 @@ import { initPageConsoleErrorCatch, openDocControls, saveDocAndAssert } from '..
import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers' import { initPayloadE2E } from '../helpers/configHelpers'
import { import {
relationFalseFilterOptionSlug,
relationOneSlug, relationOneSlug,
relationRestrictedSlug, relationRestrictedSlug,
relationTrueFilterOptionSlug,
relationTwoSlug, relationTwoSlug,
relationUpdatedExternallySlug, relationUpdatedExternallySlug,
relationWithTitleSlug, relationWithTitleSlug,
@@ -112,9 +114,9 @@ describe('fields - relationship', () => {
data: { data: {
name: 'with-existing-relations', name: 'with-existing-relations',
relationship: relationOneDoc.id, relationship: relationOneDoc.id,
relationshipReadOnly: relationOneDoc.id,
relationshipRestricted: restrictedRelation.id, relationshipRestricted: restrictedRelation.id,
relationshipWithTitle: relationWithTitle.id, relationshipWithTitle: relationWithTitle.id,
relationshipReadOnly: relationOneDoc.id,
}, },
})) as any })) as any
}) })
@@ -322,6 +324,41 @@ describe('fields - relationship', () => {
await expect(options).not.toContainText('exclude') 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 () => { test('should open document drawer from read-only relationships', async () => {
await page.goto(url.edit(docWithExistingRelations.id)) await page.goto(url.edit(docWithExistingRelations.id))
@@ -492,6 +529,6 @@ async function clearCollectionDocs(collectionSlug: string): Promise<void> {
(doc) => doc.id, (doc) => doc.id,
) )
await mapAsync(ids, async (id) => { await mapAsync(ids, async (id) => {
await payload.delete({ collection: collectionSlug, id }) await payload.delete({ id, collection: collectionSlug })
}) })
} }