Files
payloadcms/packages/ui/src/utilities/buildFormState.ts
Jacob Fletcher 9ea8a7acf0 feat: form state select (#11689)
Implements a select-like API into the form state endpoint. This follows
the same spec as the Select API on existing Payload operations, but
works on form state rather than at the db level. This means you can send
the `select` argument through the form state handler, and it will only
process and return the fields you've explicitly identified.

This is especially useful when you only need to generate a partial form
state, for example within the bulk edit form where you select only a
subset of fields to edit. There is no need to iterate all fields of the
schema, generate default values for each, and return them all through
the network. This will also simplify and reduce the amount of
client-side processing required, where we longer need to strip
unselected fields before submission.
2025-03-14 13:11:12 -04:00

245 lines
6.1 KiB
TypeScript

import type { BuildFormStateArgs, ClientConfig, ClientUser, ErrorResult, FormState } from 'payload'
import { formatErrors } from 'payload'
import { getSelectMode, reduceFieldsToValues } from 'payload/shared'
import { fieldSchemasToFormState } from '../forms/fieldSchemasToFormState/index.js'
import { renderField } from '../forms/fieldSchemasToFormState/renderField.js'
import { getClientConfig } from './getClientConfig.js'
import { getClientSchemaMap } from './getClientSchemaMap.js'
import { getSchemaMap } from './getSchemaMap.js'
import { handleFormStateLocking } from './handleFormStateLocking.js'
export type LockedState = {
isLocked: boolean
lastEditedAt: string
user: ClientUser | number | string
}
type BuildFormStateSuccessResult = {
clientConfig?: ClientConfig
errors?: never
indexPath?: string
lockedState?: LockedState
state: FormState
}
type BuildFormStateErrorResult = {
lockedState?: never
state?: never
} & (
| {
message: string
}
| ErrorResult
)
export type BuildFormStateResult = BuildFormStateErrorResult | BuildFormStateSuccessResult
export const buildFormStateHandler = async (
args: BuildFormStateArgs,
): Promise<BuildFormStateResult> => {
const { req } = args
const incomingUserSlug = req.user?.collection
const adminUserSlug = req.payload.config.admin.user
try {
// If we have a user slug, test it against the functions
if (incomingUserSlug) {
const adminAccessFunction = req.payload.collections[incomingUserSlug].config.access?.admin
// Run the admin access function from the config if it exists
if (adminAccessFunction) {
const canAccessAdmin = await adminAccessFunction({ req })
if (!canAccessAdmin) {
throw new Error('Unauthorized')
}
// Match the user collection to the global admin config
} else if (adminUserSlug !== incomingUserSlug) {
throw new Error('Unauthorized')
}
} else {
const hasUsers = await req.payload.find({
collection: adminUserSlug,
depth: 0,
limit: 1,
pagination: false,
})
// If there are users, we should not allow access because of /create-first-user
if (hasUsers.docs.length) {
throw new Error('Unauthorized')
}
}
const res = await buildFormState(args)
return res
} catch (err) {
req.payload.logger.error({ err, msg: `There was an error building form state` })
if (err.message === 'Could not find field schema for given path') {
return {
message: err.message,
}
}
if (err.message === 'Unauthorized') {
return null
}
return formatErrors(err)
}
}
export const buildFormState = async (
args: BuildFormStateArgs,
): Promise<BuildFormStateSuccessResult> => {
const {
id: idFromArgs,
collectionSlug,
data: incomingData,
docPermissions,
docPreferences,
documentFormState,
formState,
globalSlug,
initialBlockData,
initialBlockFormState,
operation,
renderAllFields,
req,
req: {
i18n,
payload,
payload: { config },
},
returnLockStatus,
schemaPath = collectionSlug || globalSlug,
select,
skipValidation,
updateLastEdited,
} = args
const selectMode = select ? getSelectMode(select) : undefined
let data = incomingData
if (!collectionSlug && !globalSlug) {
throw new Error('Either collectionSlug or globalSlug must be provided')
}
const schemaMap = getSchemaMap({
collectionSlug,
config,
globalSlug,
i18n,
})
const clientSchemaMap = getClientSchemaMap({
collectionSlug,
config: getClientConfig({ config, i18n, importMap: req.payload.importMap }),
globalSlug,
i18n,
payload,
schemaMap,
})
const id = collectionSlug ? idFromArgs : undefined
const fieldOrEntityConfig = schemaMap.get(schemaPath)
if (!fieldOrEntityConfig) {
throw new Error(`Could not find "${schemaPath}" in the fieldSchemaMap`)
}
if (
(!('fields' in fieldOrEntityConfig) ||
!fieldOrEntityConfig.fields ||
!fieldOrEntityConfig.fields.length) &&
'type' in fieldOrEntityConfig &&
fieldOrEntityConfig.type !== 'blocks'
) {
throw new Error(
`The field found in fieldSchemaMap for "${schemaPath}" does not contain any subfields.`,
)
}
// If there is a form state,
// then we can deduce data from that form state
if (formState) {
data = reduceFieldsToValues(formState, true)
}
let documentData = undefined
if (documentFormState) {
documentData = reduceFieldsToValues(documentFormState, true)
}
let blockData = initialBlockData
if (initialBlockFormState) {
blockData = reduceFieldsToValues(initialBlockFormState, true)
}
/**
* When building state for sub schemas we need to adjust:
* - `fields`
* - `parentSchemaPath`
* - `parentPath`
*
* Type assertion is fine because we wrap sub schemas in an array
* so we can safely map over them within `fieldSchemasToFormState`
*/
const fields = Array.isArray(fieldOrEntityConfig)
? fieldOrEntityConfig
: 'fields' in fieldOrEntityConfig
? fieldOrEntityConfig.fields
: [fieldOrEntityConfig]
const formStateResult = await fieldSchemasToFormState({
id,
clientFieldSchemaMap: clientSchemaMap,
collectionSlug,
data,
documentData,
fields,
fieldSchemaMap: schemaMap,
initialBlockData: blockData,
operation,
permissions: docPermissions?.fields || {},
preferences: docPreferences || { fields: {} },
previousFormState: formState,
renderAllFields,
renderFieldFn: renderField,
req,
schemaPath,
select,
selectMode,
skipValidation,
})
// Maintain form state of auth / upload fields
if (collectionSlug && formState) {
if (payload.collections[collectionSlug]?.config?.upload && formState.file) {
formStateResult.file = formState.file
}
}
let lockedStateResult
if (returnLockStatus) {
lockedStateResult = await handleFormStateLocking({
id,
collectionSlug,
globalSlug,
req,
updateLastEdited,
})
}
return {
lockedState: lockedStateResult,
state: formStateResult,
}
}