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.
This commit is contained in:
Jacob Fletcher
2025-03-14 13:11:12 -04:00
committed by GitHub
parent 3c92fbd98d
commit 9ea8a7acf0
18 changed files with 377 additions and 66 deletions

View File

@@ -13,6 +13,9 @@ const esModules = [
'path-exists', 'path-exists',
'qs-esm', 'qs-esm',
'uint8array-extras', 'uint8array-extras',
'@faceless-ui/window-info',
'@faceless-ui/modal',
'@faceless-ui/scroll-info',
].join('|') ].join('|')
import path from 'path' import path from 'path'

View File

@@ -4,7 +4,7 @@ import type { SanitizedDocumentPermissions } from '../../auth/types.js'
import type { Field, Validate } from '../../fields/config/types.js' import type { Field, Validate } from '../../fields/config/types.js'
import type { TypedLocale } from '../../index.js' import type { TypedLocale } from '../../index.js'
import type { DocumentPreferences } from '../../preferences/types.js' import type { DocumentPreferences } from '../../preferences/types.js'
import type { PayloadRequest, Where } from '../../types/index.js' import type { PayloadRequest, SelectType, Where } from '../../types/index.js'
export type Data = { export type Data = {
[key: string]: any [key: string]: any
@@ -91,6 +91,7 @@ export type BuildFormStateArgs = {
req: PayloadRequest req: PayloadRequest
returnLockStatus?: boolean returnLockStatus?: boolean
schemaPath: string schemaPath: string
select?: SelectType
skipValidation?: boolean skipValidation?: boolean
updateLastEdited?: boolean updateLastEdited?: boolean
} & ( } & (

View File

@@ -13,6 +13,8 @@ import type {
import type { Block, Field, TabAsField } from '../../config/types.js' import type { Block, Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js' import { MissingEditorProp } from '../../../errors/index.js'
import { getBlockSelect } from '../../../utilities/getBlockSelect.js'
import { stripUnselectedFields } from '../../../utilities/stripUnselectedFields.js'
import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../../config/types.js' import { fieldAffectsData, fieldShouldBeLocalized, tabHasName } from '../../config/types.js'
import { getDefaultValue } from '../../getDefaultValue.js' import { getDefaultValue } from '../../getDefaultValue.js'
import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js' import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
@@ -122,20 +124,16 @@ export const promise = async ({
delete siblingDoc[field.name] delete siblingDoc[field.name]
} }
// Strip unselected fields if (path !== 'id') {
if (fieldAffectsData(field) && select && selectMode && path !== 'id') { const shouldContinue = stripUnselectedFields({
if (selectMode === 'include') { field,
if (!select[field.name]) { select,
delete siblingDoc[field.name] selectMode,
return siblingDoc,
} })
}
if (selectMode === 'exclude') { if (!shouldContinue) {
if (select[field.name] === false) { return
delete siblingDoc[field.name]
return
}
} }
} }
@@ -454,8 +452,6 @@ export const promise = async ({
case 'blocks': { case 'blocks': {
const rows = siblingDoc[field.name] const rows = siblingDoc[field.name]
let blocksSelect = select?.[field.name]
if (Array.isArray(rows)) { if (Array.isArray(rows)) {
rows.forEach((row, rowIndex) => { rows.forEach((row, rowIndex) => {
const blockTypeToMatch = (row as JsonObject).blockType const blockTypeToMatch = (row as JsonObject).blockType
@@ -466,37 +462,11 @@ export const promise = async ({
(curBlock) => typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch, (curBlock) => typeof curBlock !== 'string' && curBlock.slug === blockTypeToMatch,
) as Block | undefined) ) as Block | undefined)
let blockSelectMode = selectMode const { blockSelect, blockSelectMode } = getBlockSelect({
block,
if (typeof blocksSelect === 'object') { select: select?.[field.name],
blocksSelect = { selectMode,
...blocksSelect, })
}
// sanitize blocks: {cta: false} to blocks: {cta: {id: true, blockType: true}}
if (selectMode === 'exclude' && blocksSelect[block.slug] === false) {
blockSelectMode = 'include'
blocksSelect[block.slug] = {
id: true,
blockType: true,
}
} else if (selectMode === 'include') {
if (!blocksSelect[block.slug]) {
blocksSelect[block.slug] = {}
}
if (typeof blocksSelect[block.slug] === 'object') {
blocksSelect[block.slug] = {
...(blocksSelect[block.slug] as object),
}
blocksSelect[block.slug]['id'] = true
blocksSelect[block.slug]['blockType'] = true
}
}
}
const blockSelect = blocksSelect?.[block.slug]
if (block) { if (block) {
traverseFields({ traverseFields({

View File

@@ -1458,6 +1458,7 @@ export { flattenAllFields } from './utilities/flattenAllFields.js'
export { default as flattenTopLevelFields } from './utilities/flattenTopLevelFields.js' export { default as flattenTopLevelFields } from './utilities/flattenTopLevelFields.js'
export { formatErrors } from './utilities/formatErrors.js' export { formatErrors } from './utilities/formatErrors.js'
export { formatLabels, formatNames, toWords } from './utilities/formatLabels.js' export { formatLabels, formatNames, toWords } from './utilities/formatLabels.js'
export { getBlockSelect } from './utilities/getBlockSelect.js'
export { getCollectionIDFieldTypes } from './utilities/getCollectionIDFieldTypes.js' export { getCollectionIDFieldTypes } from './utilities/getCollectionIDFieldTypes.js'
export { getObjectDotNotation } from './utilities/getObjectDotNotation.js' export { getObjectDotNotation } from './utilities/getObjectDotNotation.js'
export { getRequestLanguage } from './utilities/getRequestLanguage.js' export { getRequestLanguage } from './utilities/getRequestLanguage.js'
@@ -1477,6 +1478,7 @@ export { sanitizeFallbackLocale } from './utilities/sanitizeFallbackLocale.js'
export { sanitizeJoinParams } from './utilities/sanitizeJoinParams.js' export { sanitizeJoinParams } from './utilities/sanitizeJoinParams.js'
export { sanitizePopulateParam } from './utilities/sanitizePopulateParam.js' export { sanitizePopulateParam } from './utilities/sanitizePopulateParam.js'
export { sanitizeSelectParam } from './utilities/sanitizeSelectParam.js' export { sanitizeSelectParam } from './utilities/sanitizeSelectParam.js'
export { stripUnselectedFields } from './utilities/stripUnselectedFields.js'
export { traverseFields } from './utilities/traverseFields.js' export { traverseFields } from './utilities/traverseFields.js'
export type { TraverseFieldsCallback } from './utilities/traverseFields.js' export type { TraverseFieldsCallback } from './utilities/traverseFields.js'
export { buildVersionCollectionFields } from './versions/buildCollectionFields.js' export { buildVersionCollectionFields } from './versions/buildCollectionFields.js'

View File

@@ -101,8 +101,9 @@ type CreateLocalReq = (
export const createLocalReq: CreateLocalReq = async ( export const createLocalReq: CreateLocalReq = async (
{ context, fallbackLocale, locale: localeArg, req = {} as PayloadRequest, urlSuffix, user }, { context, fallbackLocale, locale: localeArg, req = {} as PayloadRequest, urlSuffix, user },
payload, payload,
) => { ): Promise<PayloadRequest> => {
const localization = payload.config?.localization const localization = payload.config?.localization
if (localization) { if (localization) {
const locale = localeArg === '*' ? 'all' : localeArg const locale = localeArg === '*' ? 'all' : localeArg
const defaultLocale = localization.defaultLocale const defaultLocale = localization.defaultLocale

View File

@@ -0,0 +1,54 @@
import type { Block } from '../fields/config/types.js'
import type { SelectMode, SelectType } from '../types/index.js'
/**
* This is used for the Select API to determine the select level of a block.
* It will ensure that `id` and `blockType` are always included in the select object.
* @returns { blockSelect: boolean | SelectType, blockSelectMode: SelectMode }
*/
export const getBlockSelect = ({
block,
select,
selectMode,
}: {
block: Block
select: SelectType[string]
selectMode: SelectMode
}): { blockSelect: boolean | SelectType; blockSelectMode: SelectMode } => {
if (typeof select === 'object') {
let blockSelectMode = selectMode
const blocksSelect = {
...select,
}
let blockSelect = blocksSelect[block.slug]
// sanitize `{ blocks: { cta: false }}` to `{ blocks: { cta: { id: true, blockType: true }}}`
if (selectMode === 'exclude' && blockSelect === false) {
blockSelectMode = 'include'
blockSelect = {
id: true,
blockType: true,
}
} else if (selectMode === 'include') {
if (!blockSelect) {
blockSelect = {}
}
if (typeof blockSelect === 'object') {
blockSelect = {
...blockSelect,
}
blockSelect['id'] = true
blockSelect['blockType'] = true
}
}
return { blockSelect, blockSelectMode }
}
return { blockSelect: select, blockSelectMode: selectMode }
}

View File

@@ -0,0 +1,43 @@
import type { Data } from '../admin/types.js'
import type { Field, TabAsField } from '../fields/config/types.js'
import type { SelectMode, SelectType } from '../types/index.js'
import { fieldAffectsData } from '../fields/config/types.js'
/**
* This is used for the Select API to strip out fields that are not selected.
* It will mutate the given data object and determine if your recursive function should continue to run.
* It is used within the `afterRead` hook as well as `getFormState`.
* @returns boolean - whether or not the recursive function should continue
*/
export const stripUnselectedFields = ({
field,
select,
selectMode,
siblingDoc,
}: {
field: Field | TabAsField
select: SelectType
selectMode: SelectMode
siblingDoc: Data
}): boolean => {
let shouldContinue = true
if (fieldAffectsData(field) && select && selectMode && field.name) {
if (selectMode === 'include') {
if (!select[field.name]) {
delete siblingDoc[field.name]
shouldContinue = false
}
}
if (selectMode === 'exclude') {
if (select[field.name] === false) {
delete siblingDoc[field.name]
shouldContinue = false
}
}
}
return shouldContinue
}

View File

@@ -10,7 +10,7 @@ import {
reduceFieldsToValues, reduceFieldsToValues,
wait, wait,
} from 'payload/shared' } from 'payload/shared'
import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import React, { useCallback, useEffect, useReducer, useRef, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import type { import type {

View File

@@ -11,10 +11,13 @@ import type {
PayloadRequest, PayloadRequest,
SanitizedFieldPermissions, SanitizedFieldPermissions,
SanitizedFieldsPermissions, SanitizedFieldsPermissions,
SelectMode,
SelectType,
Validate, Validate,
} from 'payload' } from 'payload'
import ObjectIdImport from 'bson-objectid' import ObjectIdImport from 'bson-objectid'
import { getBlockSelect } from 'payload'
import { import {
deepCopyObjectSimple, deepCopyObjectSimple,
fieldAffectsData, fieldAffectsData,
@@ -86,6 +89,8 @@ export type AddFieldStatePromiseArgs = {
*/ */
req: PayloadRequest req: PayloadRequest
schemaPath: string schemaPath: string
select?: SelectType
selectMode?: SelectMode
/** /**
* Whether to skip checking the field's condition. @default false * Whether to skip checking the field's condition. @default false
*/ */
@@ -130,6 +135,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
renderFieldFn, renderFieldFn,
req, req,
schemaPath, schemaPath,
select,
selectMode,
skipConditionChecks = false, skipConditionChecks = false,
skipValidation = false, skipValidation = false,
state, state,
@@ -247,6 +254,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
case 'array': { case 'array': {
const arrayValue = Array.isArray(data[field.name]) ? data[field.name] : [] const arrayValue = Array.isArray(data[field.name]) ? data[field.name] : []
const arraySelect = select?.[field.name]
const { promises, rows } = arrayValue.reduce( const { promises, rows } = arrayValue.reduce(
(acc, row, i: number) => { (acc, row, i: number) => {
const parentPath = path + '.' + i const parentPath = path + '.' + i
@@ -293,6 +302,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
renderAllFields: requiresRender, renderAllFields: requiresRender,
renderFieldFn, renderFieldFn,
req, req,
select: typeof arraySelect === 'object' ? arraySelect : undefined,
selectMode,
skipConditionChecks, skipConditionChecks,
skipValidation, skipValidation,
state, state,
@@ -373,6 +384,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
const { promises, rowMetadata } = blocksValue.reduce( const { promises, rowMetadata } = blocksValue.reduce(
(acc, row, i: number) => { (acc, row, i: number) => {
const blockTypeToMatch: string = row.blockType const blockTypeToMatch: string = row.blockType
const block = const block =
req.payload.blocks[blockTypeToMatch] ?? req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find( ((field.blockReferences ?? field.blocks).find(
@@ -385,6 +397,12 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
) )
} }
const { blockSelect, blockSelectMode } = getBlockSelect({
block,
select: select?.[field.name],
selectMode,
})
const parentPath = path + '.' + i const parentPath = path + '.' + i
if (block) { if (block) {
@@ -468,6 +486,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
renderAllFields: requiresRender, renderAllFields: requiresRender,
renderFieldFn, renderFieldFn,
req, req,
select: typeof blockSelect === 'object' ? blockSelect : undefined,
selectMode: blockSelectMode,
skipConditionChecks, skipConditionChecks,
skipValidation, skipValidation,
state, state,
@@ -534,6 +554,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
state[path] = fieldState state[path] = fieldState
} }
const groupSelect = select?.[field.name]
await iterateFields({ await iterateFields({
id, id,
addErrorPathToParent, addErrorPathToParent,
@@ -561,6 +583,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
renderAllFields, renderAllFields,
renderFieldFn, renderFieldFn,
req, req,
select: typeof groupSelect === 'object' ? groupSelect : undefined,
selectMode,
skipConditionChecks, skipConditionChecks,
skipValidation, skipValidation,
state, state,
@@ -685,6 +709,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
await iterateFields({ await iterateFields({
id, id,
select,
selectMode,
// passthrough parent functionality // passthrough parent functionality
addErrorPathToParent: addErrorPathToParentArg, addErrorPathToParent: addErrorPathToParentArg,
anyParentLocalized: fieldIsLocalized(field) || anyParentLocalized, anyParentLocalized: fieldIsLocalized(field) || anyParentLocalized,
@@ -717,6 +743,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
} else if (field.type === 'tabs') { } else if (field.type === 'tabs') {
const promises = field.tabs.map((tab, tabIndex) => { const promises = field.tabs.map((tab, tabIndex) => {
const isNamedTab = tabHasName(tab) const isNamedTab = tabHasName(tab)
let tabSelect: SelectType | undefined
const { const {
indexPath: tabIndexPath, indexPath: tabIndexPath,
@@ -746,8 +773,13 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
childPermissions = tabPermissions?.fields childPermissions = tabPermissions?.fields
} }
} }
if (typeof select?.[tab.name] === 'object') {
tabSelect = select?.[tab.name] as SelectType
}
} else { } else {
childPermissions = parentPermissions childPermissions = parentPermissions
tabSelect = select
} }
const pathSegments = path ? path.split('.') : [] const pathSegments = path ? path.split('.') : []
@@ -796,6 +828,8 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
renderAllFields, renderAllFields,
renderFieldFn, renderFieldFn,
req, req,
select: tabSelect,
selectMode,
skipConditionChecks, skipConditionChecks,
skipValidation, skipValidation,
state, state,

View File

@@ -1,4 +1,11 @@
import type { Data, Field as FieldSchema, PayloadRequest, User } from 'payload' import type {
Data,
Field as FieldSchema,
PayloadRequest,
SelectMode,
SelectType,
User,
} from 'payload'
import { iterateFields } from './iterateFields.js' import { iterateFields } from './iterateFields.js'
@@ -8,6 +15,8 @@ type Args = {
id?: number | string id?: number | string
locale: string | undefined locale: string | undefined
req: PayloadRequest req: PayloadRequest
select?: SelectType
selectMode?: SelectMode
siblingData: Data siblingData: Data
user: User user: User
} }
@@ -18,6 +27,8 @@ export const calculateDefaultValues = async ({
fields, fields,
locale, locale,
req, req,
select,
selectMode,
user, user,
}: Args): Promise<Data> => { }: Args): Promise<Data> => {
await iterateFields({ await iterateFields({
@@ -26,6 +37,8 @@ export const calculateDefaultValues = async ({
fields, fields,
locale, locale,
req, req,
select,
selectMode,
siblingData: data, siblingData: data,
user, user,
}) })

View File

@@ -1,4 +1,4 @@
import type { Data, Field, PayloadRequest, TabAsField, User } from 'payload' import type { Data, Field, PayloadRequest, SelectMode, SelectType, TabAsField, User } from 'payload'
import { defaultValuePromise } from './promise.js' import { defaultValuePromise } from './promise.js'
@@ -8,6 +8,8 @@ type Args<T> = {
id?: number | string id?: number | string
locale: string | undefined locale: string | undefined
req: PayloadRequest req: PayloadRequest
select?: SelectType
selectMode?: SelectMode
siblingData: Data siblingData: Data
user: User user: User
} }
@@ -18,6 +20,8 @@ export const iterateFields = async <T>({
fields, fields,
locale, locale,
req, req,
select,
selectMode,
siblingData, siblingData,
user, user,
}: Args<T>): Promise<void> => { }: Args<T>): Promise<void> => {
@@ -31,6 +35,8 @@ export const iterateFields = async <T>({
field, field,
locale, locale,
req, req,
select,
selectMode,
siblingData, siblingData,
user, user,
}), }),

View File

@@ -1,6 +1,15 @@
import type { Data, Field, FlattenedBlock, PayloadRequest, TabAsField, User } from 'payload' import type {
Data,
Field,
FlattenedBlock,
PayloadRequest,
SelectMode,
SelectType,
TabAsField,
User,
} from 'payload'
import { getDefaultValue } from 'payload' import { getBlockSelect, getDefaultValue, stripUnselectedFields } from 'payload'
import { fieldAffectsData, tabHasName } from 'payload/shared' import { fieldAffectsData, tabHasName } from 'payload/shared'
import { iterateFields } from './iterateFields.js' import { iterateFields } from './iterateFields.js'
@@ -11,6 +20,8 @@ type Args<T> = {
id?: number | string id?: number | string
locale: string | undefined locale: string | undefined
req: PayloadRequest req: PayloadRequest
select?: SelectType
selectMode?: SelectMode
siblingData: Data siblingData: Data
user: User user: User
} }
@@ -22,9 +33,22 @@ export const defaultValuePromise = async <T>({
field, field,
locale, locale,
req, req,
select,
selectMode,
siblingData, siblingData,
user, user,
}: Args<T>): Promise<void> => { }: Args<T>): Promise<void> => {
const shouldContinue = stripUnselectedFields({
field,
select,
selectMode,
siblingDoc: siblingData,
})
if (!shouldContinue) {
return
}
if (fieldAffectsData(field)) { if (fieldAffectsData(field)) {
if ( if (
typeof siblingData[field.name] === 'undefined' && typeof siblingData[field.name] === 'undefined' &&
@@ -54,6 +78,7 @@ export const defaultValuePromise = async <T>({
if (Array.isArray(rows)) { if (Array.isArray(rows)) {
const promises = [] const promises = []
const arraySelect = select?.[field.name]
rows.forEach((row) => { rows.forEach((row) => {
promises.push( promises.push(
@@ -63,6 +88,8 @@ export const defaultValuePromise = async <T>({
fields: field.fields, fields: field.fields,
locale, locale,
req, req,
select: typeof arraySelect === 'object' ? arraySelect : undefined,
selectMode,
siblingData: row, siblingData: row,
user, user,
}), }),
@@ -79,14 +106,22 @@ export const defaultValuePromise = async <T>({
if (Array.isArray(rows)) { if (Array.isArray(rows)) {
const promises = [] const promises = []
rows.forEach((row) => { rows.forEach((row) => {
const blockTypeToMatch: string = row.blockType const blockTypeToMatch: string = row.blockType
const block = const block =
req.payload.blocks[blockTypeToMatch] ?? req.payload.blocks[blockTypeToMatch] ??
((field.blockReferences ?? field.blocks).find( ((field.blockReferences ?? field.blocks).find(
(blockType) => typeof blockType !== 'string' && blockType.slug === blockTypeToMatch, (blockType) => typeof blockType !== 'string' && blockType.slug === blockTypeToMatch,
) as FlattenedBlock | undefined) ) as FlattenedBlock | undefined)
const { blockSelect, blockSelectMode } = getBlockSelect({
block,
select: select?.[field.name],
selectMode,
})
if (block) { if (block) {
row.blockType = blockTypeToMatch row.blockType = blockTypeToMatch
@@ -97,6 +132,8 @@ export const defaultValuePromise = async <T>({
fields: block.fields, fields: block.fields,
locale, locale,
req, req,
select: typeof blockSelect === 'object' ? blockSelect : undefined,
selectMode: blockSelectMode,
siblingData: row, siblingData: row,
user, user,
}), }),
@@ -117,6 +154,8 @@ export const defaultValuePromise = async <T>({
fields: field.fields, fields: field.fields,
locale, locale,
req, req,
select,
selectMode,
siblingData, siblingData,
user, user,
}) })
@@ -130,12 +169,16 @@ export const defaultValuePromise = async <T>({
const groupData = siblingData[field.name] as Record<string, unknown> const groupData = siblingData[field.name] as Record<string, unknown>
const groupSelect = select?.[field.name]
await iterateFields({ await iterateFields({
id, id,
data, data,
fields: field.fields, fields: field.fields,
locale, locale,
req, req,
select: typeof groupSelect === 'object' ? groupSelect : undefined,
selectMode,
siblingData: groupData, siblingData: groupData,
user, user,
}) })
@@ -145,14 +188,24 @@ export const defaultValuePromise = async <T>({
case 'tab': { case 'tab': {
let tabSiblingData let tabSiblingData
if (tabHasName(field)) {
const isNamedTab = tabHasName(field)
let tabSelect: SelectType | undefined
if (isNamedTab) {
if (typeof siblingData[field.name] !== 'object') { if (typeof siblingData[field.name] !== 'object') {
siblingData[field.name] = {} siblingData[field.name] = {}
} }
tabSiblingData = siblingData[field.name] as Record<string, unknown> tabSiblingData = siblingData[field.name] as Record<string, unknown>
if (typeof select?.[field.name] === 'object') {
tabSelect = select?.[field.name] as SelectType
}
} else { } else {
tabSiblingData = siblingData tabSiblingData = siblingData
tabSelect = select
} }
await iterateFields({ await iterateFields({
@@ -161,6 +214,8 @@ export const defaultValuePromise = async <T>({
fields: field.fields, fields: field.fields,
locale, locale,
req, req,
select: tabSelect,
selectMode,
siblingData: tabSiblingData, siblingData: tabSiblingData,
user, user,
}) })
@@ -175,6 +230,8 @@ export const defaultValuePromise = async <T>({
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })), fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
locale, locale,
req, req,
select,
selectMode,
siblingData, siblingData,
user, user,
}) })

View File

@@ -8,6 +8,8 @@ import type {
FormStateWithoutComponents, FormStateWithoutComponents,
PayloadRequest, PayloadRequest,
SanitizedFieldsPermissions, SanitizedFieldsPermissions,
SelectMode,
SelectType,
} from 'payload' } from 'payload'
import type { RenderFieldMethod } from './types.js' import type { RenderFieldMethod } from './types.js'
@@ -70,6 +72,8 @@ type Args = {
renderFieldFn?: RenderFieldMethod renderFieldFn?: RenderFieldMethod
req: PayloadRequest req: PayloadRequest
schemaPath: string schemaPath: string
select?: SelectType
selectMode?: SelectMode
skipValidation?: boolean skipValidation?: boolean
} }
@@ -90,6 +94,8 @@ export const fieldSchemasToFormState = async ({
renderFieldFn, renderFieldFn,
req, req,
schemaPath, schemaPath,
select,
selectMode,
skipValidation, skipValidation,
}: Args): Promise<FormState> => { }: Args): Promise<FormState> => {
if (!clientFieldSchemaMap && renderFieldFn) { if (!clientFieldSchemaMap && renderFieldFn) {
@@ -109,6 +115,8 @@ export const fieldSchemasToFormState = async ({
fields, fields,
locale: req.locale, locale: req.locale,
req, req,
select,
selectMode,
siblingData: dataWithDefaultValues, siblingData: dataWithDefaultValues,
user: req.user, user: req.user,
}) })
@@ -142,6 +150,8 @@ export const fieldSchemasToFormState = async ({
renderAllFields, renderAllFields,
renderFieldFn, renderFieldFn,
req, req,
select,
selectMode,
skipValidation, skipValidation,
state, state,
}) })

View File

@@ -8,8 +8,11 @@ import type {
FormStateWithoutComponents, FormStateWithoutComponents,
PayloadRequest, PayloadRequest,
SanitizedFieldsPermissions, SanitizedFieldsPermissions,
SelectMode,
SelectType,
} from 'payload' } from 'payload'
import { stripUnselectedFields } from 'payload'
import { getFieldPaths } from 'payload/shared' import { getFieldPaths } from 'payload/shared'
import type { AddFieldStatePromiseArgs } from './addFieldStatePromise.js' import type { AddFieldStatePromiseArgs } from './addFieldStatePromise.js'
@@ -61,6 +64,8 @@ type Args = {
renderAllFields: boolean renderAllFields: boolean
renderFieldFn: RenderFieldMethod renderFieldFn: RenderFieldMethod
req: PayloadRequest req: PayloadRequest
select?: SelectType
selectMode?: SelectMode
/** /**
* Whether to skip checking the field's condition. @default false * Whether to skip checking the field's condition. @default false
*/ */
@@ -101,6 +106,8 @@ export const iterateFields = async ({
renderAllFields, renderAllFields,
renderFieldFn: renderFieldFn, renderFieldFn: renderFieldFn,
req, req,
select,
selectMode,
skipConditionChecks = false, skipConditionChecks = false,
skipValidation = false, skipValidation = false,
state = {}, state = {},
@@ -118,6 +125,19 @@ export const iterateFields = async ({
parentSchemaPath, parentSchemaPath,
}) })
if (path !== 'id') {
const shouldContinue = stripUnselectedFields({
field,
select,
selectMode,
siblingDoc: data,
})
if (!shouldContinue) {
return
}
}
const pathSegments = path ? path.split('.') : [] const pathSegments = path ? path.split('.') : []
if (!skipConditionChecks) { if (!skipConditionChecks) {
@@ -174,6 +194,8 @@ export const iterateFields = async ({
renderFieldFn, renderFieldFn,
req, req,
schemaPath, schemaPath,
select,
selectMode,
skipConditionChecks, skipConditionChecks,
skipValidation, skipValidation,
state, state,

View File

@@ -1,7 +1,7 @@
import type { BuildFormStateArgs, ClientConfig, ClientUser, ErrorResult, FormState } from 'payload' import type { BuildFormStateArgs, ClientConfig, ClientUser, ErrorResult, FormState } from 'payload'
import { formatErrors } from 'payload' import { formatErrors } from 'payload'
import { reduceFieldsToValues } from 'payload/shared' import { getSelectMode, reduceFieldsToValues } from 'payload/shared'
import { fieldSchemasToFormState } from '../forms/fieldSchemasToFormState/index.js' import { fieldSchemasToFormState } from '../forms/fieldSchemasToFormState/index.js'
import { renderField } from '../forms/fieldSchemasToFormState/renderField.js' import { renderField } from '../forms/fieldSchemasToFormState/renderField.js'
@@ -117,10 +117,13 @@ export const buildFormState = async (
}, },
returnLockStatus, returnLockStatus,
schemaPath = collectionSlug || globalSlug, schemaPath = collectionSlug || globalSlug,
select,
skipValidation, skipValidation,
updateLastEdited, updateLastEdited,
} = args } = args
const selectMode = select ? getSelectMode(select) : undefined
let data = incomingData let data = incomingData
if (!collectionSlug && !globalSlug) { if (!collectionSlug && !globalSlug) {
@@ -210,6 +213,8 @@ export const buildFormState = async (
renderFieldFn: renderField, renderFieldFn: renderField,
req, req,
schemaPath, schemaPath,
select,
selectMode,
skipValidation, skipValidation,
}) })

View File

@@ -855,6 +855,7 @@ describe('General', () => {
test('should not override un-edited values in bulk edit if it has a defaultValue', async () => { test('should not override un-edited values in bulk edit if it has a defaultValue', async () => {
await deleteAllPosts() await deleteAllPosts()
const post1Title = 'Post' const post1Title = 'Post'
const postData = { const postData = {
title: 'Post', title: 'Post',
arrayOfFields: [ arrayOfFields: [
@@ -879,6 +880,7 @@ describe('General', () => {
], ],
defaultValueField: 'not the default value', defaultValueField: 'not the default value',
} }
const updatedPostTitle = `${post1Title} (Updated)` const updatedPostTitle = `${post1Title} (Updated)`
await createPost(postData) await createPost(postData)
await page.goto(postsUrl.list) await page.goto(postsUrl.list)
@@ -890,10 +892,8 @@ describe('General', () => {
hasText: exactText('Title'), hasText: exactText('Title'),
}) })
await expect(titleOption).toBeVisible()
await titleOption.click() await titleOption.click()
const titleInput = page.locator('#field-title') const titleInput = page.locator('#field-title')
await expect(titleInput).toBeVisible()
await titleInput.fill(updatedPostTitle) await titleInput.fill(updatedPostTitle)
await page.locator('.form-submit button[type="submit"].edit-many__publish').click() await page.locator('.form-submit button[type="submit"].edit-many__publish').click()

View File

@@ -269,10 +269,6 @@ export interface Post {
* This is a very long description that takes many characters to complete and hopefully will wrap instead of push the sidebar open, lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum voluptates. Quisquam, voluptatum voluptates. * This is a very long description that takes many characters to complete and hopefully will wrap instead of push the sidebar open, lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum voluptates. Quisquam, voluptatum voluptates.
*/ */
sidebarField?: string | null; sidebarField?: string | null;
/**
* This field should only validate on submit. Try typing "Not allowed" and submitting the form.
*/
validateUsingEvent?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _status?: ('draft' | 'published') | null;
@@ -719,7 +715,6 @@ export interface PostsSelect<T extends boolean = true> {
disableListColumnText?: T; disableListColumnText?: T;
disableListFilterText?: T; disableListFilterText?: T;
sidebarField?: T; sidebarField?: T;
validateUsingEvent?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
_status?: T; _status?: T;

View File

@@ -1,6 +1,8 @@
import type { Payload } from 'payload' import type { Payload, User } from 'payload'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import path from 'path' import path from 'path'
import { createLocalReq } from 'payload'
import { fileURLToPath } from 'url' import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js' import type { NextRESTClient } from '../helpers/NextRESTClient.js'
@@ -12,6 +14,7 @@ import { postsSlug } from './collections/Posts/index.js'
let payload: Payload let payload: Payload
let token: string let token: string
let restClient: NextRESTClient let restClient: NextRESTClient
let user: User
const { email, password } = devUser const { email, password } = devUser
const filename = fileURLToPath(import.meta.url) const filename = fileURLToPath(import.meta.url)
@@ -22,8 +25,7 @@ describe('Form State', () => {
// Boilerplate test setup/teardown // Boilerplate test setup/teardown
// --__--__--__--__--__--__--__--__--__ // --__--__--__--__--__--__--__--__--__
beforeAll(async () => { beforeAll(async () => {
const initialized = await initPayloadInt(dirname) ;({ payload, restClient } = await initPayloadInt(dirname))
;({ payload, restClient } = initialized)
const data = await restClient const data = await restClient
.POST('/users/login', { .POST('/users/login', {
@@ -35,6 +37,7 @@ describe('Form State', () => {
.then((res) => res.json()) .then((res) => res.json())
token = data.token token = data.token
user = data.user
}) })
afterAll(async () => { afterAll(async () => {
@@ -43,5 +46,97 @@ describe('Form State', () => {
} }
}) })
it.todo('should execute form state endpoint') it('should build entire form state', async () => {
const req = await createLocalReq({ user }, payload)
const postData = await payload.create({
collection: postsSlug,
data: {
title: 'Test Post',
},
})
const { state } = await buildFormState({
id: postData.id,
collectionSlug: postsSlug,
data: postData,
docPermissions: {
create: true,
delete: true,
fields: true,
read: true,
readVersions: true,
update: true,
},
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
})
expect(state).toMatchObject({
title: {
value: postData.title,
initialValue: postData.title,
},
updatedAt: {
value: postData.updatedAt,
initialValue: postData.updatedAt,
},
createdAt: {
value: postData.createdAt,
initialValue: postData.createdAt,
},
renderTracker: {},
validateUsingEvent: {},
blocks: {
initialValue: 0,
requiresRender: false,
rows: [],
value: 0,
},
})
})
it('should use `select` to build partial form state with only specified fields', async () => {
const req = await createLocalReq({ user }, payload)
const postData = await payload.create({
collection: postsSlug,
data: {
title: 'Test Post',
},
})
const { state } = await buildFormState({
id: postData.id,
collectionSlug: postsSlug,
data: postData,
docPermissions: undefined,
docPreferences: {
fields: {},
},
documentFormState: undefined,
operation: 'update',
renderAllFields: false,
req,
schemaPath: postsSlug,
select: {
title: true,
},
})
expect(state).toStrictEqual({
title: {
value: postData.title,
initialValue: postData.title,
},
})
})
it.todo('should skip validation if specified')
}) })