fix(ui): executes filterOptions on the server (#5335)

This commit is contained in:
Jacob Fletcher
2024-03-14 16:53:24 -04:00
committed by GitHub
parent bff83f1785
commit f85e96acac
21 changed files with 112 additions and 151 deletions

View File

@@ -1,4 +1,5 @@
import type { ClientValidate, Field } from '../../fields/config/types.js'
import type { Where } from '../../types/index.js'
export type Data = {
[key: string]: any
@@ -11,11 +12,16 @@ export type Row = {
id: string
}
export type FilterOptionsResult = {
[relation: string]: Where | boolean
}
export type FormField = {
disableFormData?: boolean
errorMessage?: string
errorPaths?: Set<string>
fieldSchema?: Field
filterOptions?: FilterOptionsResult
initialValue: unknown
passesCondition?: boolean
rows?: Row[]

View File

@@ -23,9 +23,10 @@ export type {
DescriptionComponent,
DescriptionFunction,
} from './forms/FieldDescription.js'
export type { Data, FormField, FormState, Row } from './forms/Form.js'
export type { Data, FilterOptionsResult, FormField, FormState, Row } from './forms/Form.js'
export type { LabelProps } from './forms/Label.js'
export type { RowLabel, RowLabelComponent } from './forms/RowLabel.js'
export type {
AdminViewComponent,
AdminViewProps,

View File

@@ -32,7 +32,8 @@ export type ServerOnlyGlobalAdminProperties = keyof Pick<
export type ServerOnlyLivePreviewProperties = keyof Pick<LivePreviewConfig, 'url'>
export type ServerOnlyFieldProperties =
| 'editor'
| 'editor' // This is a `richText` only property
| 'filterOptions' // This is a `relationship` and `upload` only property
| 'label'
| keyof Pick<FieldBase, 'access' | 'defaultValue' | 'hooks' | 'validate'>
@@ -79,8 +80,8 @@ export const sanitizeField = (f: Field) => {
'validate',
'defaultValue',
'label',
// This is a `richText` only property
'editor',
'filterOptions', // This is a `relationship` and `upload` only property
'editor', // This is a `richText` only property
// `fields`
// `blocks`
// `tabs`

View File

@@ -1,64 +0,0 @@
import type { FilterOptions } from 'payload/types'
import equal from 'deep-equal'
import { useEffect } from 'react'
import type { FilterOptionsResult } from '../../forms/fields/Relationship/types.js'
import { useAllFormFields } from '../../forms/Form/context.js'
import getSiblingData from '../../forms/Form/getSiblingData.js'
import reduceFieldsToValues from '../../forms/Form/reduceFieldsToValues.js'
import { getFilterOptionsQuery } from '../../forms/fields/getFilterOptionsQuery.js'
import { useAuth } from '../../providers/Auth/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
type Args = {
filterOptions: FilterOptions
filterOptionsResult: FilterOptionsResult
path: string
relationTo: string | string[]
setFilterOptionsResult: (optionFilters: FilterOptionsResult) => void
}
export const GetFilterOptions = ({
filterOptions,
filterOptionsResult,
path,
relationTo,
setFilterOptionsResult,
}: Args): null => {
const [fields] = useAllFormFields()
const { id } = useDocumentInfo()
const { user } = useAuth()
useEffect(() => {
const data = reduceFieldsToValues(fields, true)
const siblingData = getSiblingData(fields, path)
const getFilterOptions = async () => {
const newFilterOptionsResult = await getFilterOptionsQuery(filterOptions, {
id,
data,
relationTo,
siblingData,
user,
})
if (!equal(newFilterOptionsResult, filterOptionsResult)) {
setFilterOptionsResult(newFilterOptionsResult)
}
}
getFilterOptions()
}, [
fields,
filterOptions,
id,
relationTo,
user,
path,
filterOptionsResult,
setFilterOptionsResult,
])
return null
}

View File

@@ -1,9 +1,7 @@
import type { SanitizedCollectionConfig } from 'payload/types'
import type { FilterOptionsResult, SanitizedCollectionConfig } from 'payload/types'
import type React from 'react'
import type { HTMLAttributes } from 'react'
import type { FilterOptionsResult } from '../../forms/fields/Relationship/types.js'
export type ListDrawerProps = {
collectionSlugs: string[]
customHeader?: React.ReactNode

View File

@@ -21,6 +21,8 @@ export { RenderFields } from '../forms/RenderFields/index.js'
export { useRowLabel } from '../forms/RowLabel/Context/index.js'
export { default as FormSubmit } from '../forms/Submit/index.js'
export { default as Submit } from '../forms/Submit/index.js'
export { buildStateFromSchema } from '../forms/buildStateFromSchema/index.js'
export type { BuildFormStateArgs } from '../forms/buildStateFromSchema/index.js'
export { default as SectionTitle } from '../forms/fields/Blocks/SectionTitle/index.js'
export { CheckboxInput } from '../forms/fields/Checkbox/Input.js'
export { default as Checkbox } from '../forms/fields/Checkbox/index.js'
@@ -38,16 +40,14 @@ export { default as Text } from '../forms/fields/Text/index.js'
export type { Props as TextFieldProps } from '../forms/fields/Text/types.js'
export { type TextAreaInputProps, TextareaInput } from '../forms/fields/Textarea/Input.js'
export { default as Textarea } from '../forms/fields/Textarea/index.js'
export { UploadInput, type UploadInputProps } from '../forms/fields/Upload/Input.js'
export { default as UploadField } from '../forms/fields/Upload/index.js'
export { fieldTypes } from '../forms/fields/index.js'
export { fieldBaseClass } from '../forms/fields/shared.js'
export { useField } from '../forms/useField/index.js'
export type { FieldType, Options } from '../forms/useField/types.js'
export { default as buildStateFromSchema } from '../forms/utilities/buildStateFromSchema/index.js'
export type { BuildFormStateArgs } from '../forms/utilities/buildStateFromSchema/index.js'
export { withCondition } from '../forms/withCondition/index.js'
export { buildComponentMap } from '../utilities/buildComponentMap/index.js'

View File

@@ -11,6 +11,7 @@ import ObjectIdImport from 'bson-objectid'
import { fieldAffectsData, fieldHasSubFields, tabHasName } from 'payload/types'
import { getDefaultValue } from 'payload/utilities'
import { getFilterOptionsQuery } from './getFilterOptionsQuery.js'
import { iterateFields } from './iterateFields.js'
const ObjectId = (ObjectIdImport.default ||
@@ -92,6 +93,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
if (fieldAffectsData(field)) {
const validate = operation === 'update' ? field.validate : undefined
const fieldState: FormField = {
errorPaths: new Set(),
fieldSchema: includeSchema ? field : undefined,
@@ -375,6 +377,18 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
}
case 'relationship': {
if (typeof field.filterOptions === 'function') {
const query = await getFilterOptionsQuery(field.filterOptions, {
id,
data: fullData,
relationTo: field.relationTo,
siblingData: data,
user: req.user,
})
fieldState.filterOptions = query
}
if (field.hasMany) {
const relationshipValue = Array.isArray(valueWithDefault)
? valueWithDefault.map((relationship) => {
@@ -433,6 +447,18 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
}
case 'upload': {
if (typeof field.filterOptions === 'function') {
const query = await getFilterOptionsQuery(field.filterOptions, {
id,
data: fullData,
relationTo: field.relationTo,
siblingData: data,
user: req.user,
})
fieldState.filterOptions = query
}
const relationshipValue =
valueWithDefault && typeof valueWithDefault === 'object' && 'id' in valueWithDefault
? valueWithDefault.id

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-undef */
import buildStateFromSchema from './index.js'
describe('Form - buildStateFromSchema', () => {

View File

@@ -5,8 +5,11 @@ export const getFilterOptionsQuery = async (
options: Omit<FilterOptionsProps, 'relationTo'> & { relationTo: string | string[] },
): Promise<{ [collection: string]: Where }> => {
const { relationTo } = options
const relations = Array.isArray(relationTo) ? relationTo : [relationTo]
const query = {}
if (typeof filterOptions !== 'undefined') {
await Promise.all(
relations.map(async (relation) => {
@@ -14,9 +17,11 @@ 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 } }
@@ -24,5 +29,6 @@ export const getFilterOptionsQuery = async (
}),
)
}
return query
}

View File

@@ -24,7 +24,7 @@ export type BuildFormStateArgs = {
schemaPath: string
}
const buildStateFromSchema = async (args: Args): Promise<FormState> => {
export const buildStateFromSchema = async (args: Args): Promise<FormState> => {
const { id, data: fullData = {}, fieldSchema, operation, preferences, req } = args
if (fieldSchema) {
@@ -49,5 +49,3 @@ const buildStateFromSchema = async (args: Args): Promise<FormState> => {
return {}
}
export default buildStateFromSchema

View File

@@ -7,9 +7,8 @@ import qs from 'qs'
import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import type { DocumentDrawerProps } from '../../../elements/DocumentDrawer/types.js'
import type { FilterOptionsResult, GetResults, Option, Props, Value } from './types.js'
import type { GetResults, Option, Props, Value } from './types.js'
import { GetFilterOptions } from '../../../elements/GetFilterOptions/index.js'
import ReactSelect from '../../../elements/ReactSelect/index.js'
import { useDebouncedCallback } from '../../../hooks/useDebouncedCallback.js'
import { useAuth } from '../../../providers/Auth/index.js'
@@ -57,7 +56,6 @@ const Relationship: React.FC<Props> = (props) => {
const relationTo = 'relationTo' in props ? props?.relationTo : undefined
const hasMany = 'hasMany' in props ? props?.hasMany : undefined
const filterOptions = 'filterOptions' in props ? props?.filterOptions : undefined
const sortOptions = 'sortOptions' in props ? props?.sortOptions : undefined
const isSortable = 'isSortable' in props ? props?.isSortable : true
const allowCreate = 'allowCreate' in props ? props?.allowCreate : true
@@ -71,7 +69,6 @@ const Relationship: React.FC<Props> = (props) => {
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1)
const [lastLoadedPage, setLastLoadedPage] = useState<Record<string, number>>({})
const [errorLoading, setErrorLoading] = useState('')
const [filterOptionsResult, setFilterOptionsResult] = useState<FilterOptionsResult>()
const [search, setSearch] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [hasLoadedFirstPage, setHasLoadedFirstPage] = useState(false)
@@ -87,7 +84,9 @@ const Relationship: React.FC<Props> = (props) => {
[validate, required],
)
const { initialValue, path, setValue, showError, value } = useField<Value | Value[]>({
const { filterOptions, initialValue, path, setValue, showError, value } = useField<
Value | Value[]
>({
path: pathFromProps || name,
validate: memoizedValidate,
})
@@ -123,7 +122,8 @@ const Relationship: React.FC<Props> = (props) => {
if (!errorLoading) {
await relationsToFetch.reduce(async (priorRelation, relation) => {
const relationFilterOption = filterOptionsResult?.[relation]
const relationFilterOption = filterOptions?.[relation]
let lastLoadedPageToUse
if (search !== searchArg) {
lastLoadedPageToUse = 1
@@ -246,7 +246,7 @@ const Relationship: React.FC<Props> = (props) => {
lastLoadedPage,
collections,
locale,
filterOptionsResult,
filterOptions,
serverURL,
sortOptions,
api,
@@ -362,7 +362,7 @@ const Relationship: React.FC<Props> = (props) => {
setEnableWordBoundarySearch(!isIdOnly)
}, [relationTo, collections])
// When (`relationTo` || `filterOptionsResult` || `locale`) changes, reset component
// When (`relationTo` || `filterOptions` || `locale`) changes, reset component
// Note - effect should not run on first run
useEffect(() => {
if (firstRun.current) {
@@ -374,7 +374,7 @@ const Relationship: React.FC<Props> = (props) => {
setLastFullyLoadedRelation(-1)
setLastLoadedPage({})
setHasLoadedFirstPage(false)
}, [relationTo, filterOptionsResult, locale])
}, [relationTo, filterOptions, locale])
const onSave = useCallback<DocumentDrawerProps['onSave']>(
(args) => {
@@ -435,15 +435,6 @@ const Relationship: React.FC<Props> = (props) => {
>
{Error}
{Label}
<GetFilterOptions
{...{
filterOptions,
filterOptionsResult,
path,
relationTo,
setFilterOptionsResult,
}}
/>
{!errorLoading && (
<div className={`${baseClass}__wrap`}>
<ReactSelect

View File

@@ -1,7 +1,6 @@
import type { I18n } from '@payloadcms/translations'
import type { SanitizedCollectionConfig } from 'payload/types'
import type { SanitizedConfig } from 'payload/types'
import type { Where } from 'payload/types'
import type { FormFieldBase } from '../shared.js'
@@ -59,7 +58,3 @@ export type GetResults = (args: {
sort?: boolean
value?: Value | Value[]
}) => Promise<void>
export type FilterOptionsResult = {
[relation: string]: Where | boolean
}

View File

@@ -1,13 +1,12 @@
'use client'
import type { SanitizedCollectionConfig, UploadField } from 'payload/types'
import type { FilterOptionsResult, SanitizedCollectionConfig, UploadField } from 'payload/types'
import { getTranslation } from '@payloadcms/translations'
import React, { useCallback, useEffect, useState } from 'react'
import type { DocumentDrawerProps } from '../../../elements/DocumentDrawer/types.js'
import type { ListDrawerProps } from '../../../elements/ListDrawer/types.js'
import type { FilterOptionsResult } from '../Relationship/types.js'
import { Button } from '../../../elements/Button/index.js'
import { useDocumentDrawer } from '../../../elements/DocumentDrawer/index.js'
@@ -23,7 +22,7 @@ const baseClass = 'upload'
export type UploadInputProps = FormFieldBase & {
api?: string
collection?: SanitizedCollectionConfig
filterOptions?: UploadField['filterOptions']
filterOptions?: FilterOptionsResult
onChange?: (e) => void
relationTo?: UploadField['relationTo']
serverURL?: string
@@ -34,12 +33,12 @@ export type UploadInputProps = FormFieldBase & {
export const UploadInput: React.FC<UploadInputProps> = (props) => {
const {
Description,
Error,
Label: LabelFromProps,
api = '/api',
className,
collection,
filterOptions,
label,
onChange,
readOnly,
@@ -59,7 +58,6 @@ export const UploadInput: React.FC<UploadInputProps> = (props) => {
const [file, setFile] = useState(undefined)
const [missingFile, setMissingFile] = useState(false)
const [collectionSlugs] = useState([collection?.slug])
const [filterOptionsResult, setFilterOptionsResult] = useState<FilterOptionsResult>()
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer }] = useDocumentDrawer({
collectionSlug: collectionSlugs[0],
@@ -67,7 +65,7 @@ export const UploadInput: React.FC<UploadInputProps> = (props) => {
const [ListDrawer, ListDrawerToggler, { closeDrawer: closeListDrawer }] = useListDrawer({
collectionSlugs,
filterOptions: filterOptionsResult,
filterOptions,
})
useEffect(() => {
@@ -131,15 +129,6 @@ export const UploadInput: React.FC<UploadInputProps> = (props) => {
width,
}}
>
{/* <GetFilterOptions
{...{
filterOptions,
filterOptionsResult,
path,
relationTo,
setFilterOptionsResult,
}}
/> */}
{Error}
{Label}
{collection?.upload && (

View File

@@ -15,7 +15,6 @@ const Upload: React.FC<Props> = (props) => {
Error,
Label: LabelFromProps,
className,
filterOptions,
label,
path: pathFromProps,
readOnly,
@@ -45,7 +44,7 @@ const Upload: React.FC<Props> = (props) => {
[validate, required],
)
const { path, setValue, showError, value } = useField<string>({
const { filterOptions, path, setValue, showError, value } = useField<string>({
path: pathFromProps,
validate: memoizedValidate,
})

View File

@@ -1,4 +1,4 @@
import type { FieldPermissions, User } from 'payload/auth'
import type { User } from 'payload/auth'
import type { Locale, SanitizedLocalizationConfig } from 'payload/config'
import type {
ArrayField,
@@ -51,6 +51,21 @@ export type FormFieldBase = {
validate?: Validate
width?: string
} & (
| {
// For `array` fields
label?: RowLabel
labels?: ArrayField['labels']
maxRows?: ArrayField['maxRows']
minRows?: ArrayField['minRows']
}
| {
// For `blocks` fields
blocks?: ReducedBlock[]
labels?: BlockField['labels']
maxRows?: BlockField['maxRows']
minRows?: BlockField['minRows']
slug?: string
}
| {
// For `code` fields
editorOptions?: CodeField['admin']['editorOptions']
@@ -68,11 +83,25 @@ export type FormFieldBase = {
// For `json` fields
editorOptions?: JSONField['admin']['editorOptions']
}
| {
// For `number` fields
hasMany?: boolean
max?: number
maxRows?: number
min?: number
step?: number
}
| {
// For `radio` fields
layout?: 'horizontal' | 'vertical'
options?: Option[]
}
| {
// For `relationship` fields
allowCreate?: RelationshipField['admin']['allowCreate']
relationTo?: RelationshipField['relationTo']
sortOptions?: RelationshipField['admin']['sortOptions']
}
| {
// For `richText` fields
richTextComponentMap?: Map<string, MappedField[] | React.ReactNode>
@@ -90,36 +119,6 @@ export type FormFieldBase = {
// For `upload` fields
relationTo?: UploadField['relationTo']
}
| {
allowCreate?: RelationshipField['admin']['allowCreate']
filterOptions?: RelationshipField['filterOptions']
// For `relationship` fields
relationTo?: RelationshipField['relationTo']
sortOptions?: RelationshipField['admin']['sortOptions']
}
| {
blocks?: ReducedBlock[]
labels?: BlockField['labels']
maxRows?: BlockField['maxRows']
minRows?: BlockField['minRows']
// For `blocks` fields
slug?: string
}
| {
hasMany?: boolean
max?: number
maxRows?: number
min?: number
// For `number` fields
step?: number
}
| {
label?: RowLabel
labels?: ArrayField['labels']
maxRows?: ArrayField['maxRows']
// For `array` fields
minRows?: ArrayField['minRows']
}
| {
tabs?: MappedTab[]
}

View File

@@ -41,6 +41,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
const { getData, getDataByPath, getSiblingData, setModified } = useForm()
const filterOptions = field?.filterOptions
const value = field?.value as T
const initialValue = field?.initialValue as T
const valid = typeof field?.valid === 'boolean' ? field.valid : true
@@ -79,6 +80,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
const result: FieldType<T> = useMemo(
() => ({
errorMessage: field?.errorMessage,
filterOptions,
formProcessing: processing,
formSubmitted: submitted,
initialValue,
@@ -106,6 +108,7 @@ export const useField = <T,>(options: Options): FieldType<T> => {
schemaPath,
readOnly,
permissions,
filterOptions,
],
)

View File

@@ -1,4 +1,4 @@
import type { ClientValidate, FieldPermissions, Row } from 'payload/types'
import type { ClientValidate, FieldPermissions, FilterOptionsResult, Row } from 'payload/types'
export type Options = {
disableFormData?: boolean
@@ -12,6 +12,7 @@ export type Options = {
export type FieldType<T> = {
errorMessage?: string
filterOptions?: FilterOptionsResult
formProcessing: boolean
formSubmitted: boolean
initialValue?: T

View File

@@ -1,6 +1,6 @@
import type { FormState, SanitizedConfig } from 'payload/types'
import type { BuildFormStateArgs } from '../forms/utilities/buildStateFromSchema/index.js'
import type { BuildFormStateArgs } from '../forms/buildStateFromSchema/index.js'
export const getFormState = async (args: {
apiRoute: SanitizedConfig['routes']['api']

View File

@@ -21,6 +21,17 @@ export const PostsCollection: CollectionConfig = {
{
name: 'relationship',
type: 'relationship',
filterOptions: ({ id }) => {
return {
where: [
{
id: {
not_equals: id,
},
},
],
}
},
relationTo: ['posts'],
},
{

View File

@@ -88,7 +88,7 @@
"./packages/graphql/src"
],
"@payload-config": [
"./test/access-control/config.ts"
"./test/_community/config.ts"
]
}
},