Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,22 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
fieldState.fieldSchema = field
}

// Short-circuit to prevent hidden fields from recursing and rendering.
// Note: `tab` is excluded bc tab visibility is keyed by `field.id` rather than `path`.
// The tab branch below owns that write and the skip-recursion.
if (passesCondition === false && field.type !== 'tab') {
if (fieldAffectsData(field) && data?.[field.name] !== undefined) {
fieldState.value = data[field.name]
fieldState.initialValue = data[field.name]
}

if (!filter || filter(args)) {
state[path] = fieldState
}

return
}

if (fieldAffectsData(field) && !fieldIsHiddenOrDisabled(field) && field.type !== 'tab') {
fieldPermissions =
parentPermissions === true
Expand Down Expand Up @@ -808,10 +824,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
state[path] = {
disableFormData: true,
}

if (passesCondition === false) {
state[path].passesCondition = false
}
}

await iterateFields({
Expand Down Expand Up @@ -851,18 +863,10 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
})
} else if (field.type === 'tab') {
const isNamedTab = tabHasName(field)
let tabSelect: SelectType | undefined

const tabField: TabAsField = {
...field,
type: 'tab',
}

let childPermissions: SanitizedFieldsPermissions = undefined

if (isNamedTab) {
const shouldContinue = stripUnselectedFields({
field: tabField,
field: { ...field, type: 'tab' },
select,
selectMode,
siblingDoc: data?.[field.name] || {},
Expand All @@ -871,16 +875,34 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
if (!shouldContinue) {
return
}
}

// Tab visibility on the client is keyed by `field.id`, not `path` (like all other fields).
if (field?.id) {
state[field.id] = {
passesCondition,
}

// Flag newly added tab entries so the client accepts them during merge.
// Otherwise, tabs revealed after a hidden ancestor becomes visible would never make it into client form state.
if (!renderAllFields && !previousFormState?.[field.id]) {
state[field.id].addedByServer = true
}
}

if (!passesCondition) {
return
}

let childPermissions: SanitizedFieldsPermissions
let tabSelect: SelectType | undefined

if (isNamedTab) {
if (parentPermissions === true) {
childPermissions = true
} else {
const tabPermissions = parentPermissions?.[field.name]
if (tabPermissions === true) {
childPermissions = true
} else {
childPermissions = tabPermissions?.fields
}
childPermissions = tabPermissions === true ? true : tabPermissions?.fields
}

if (typeof select?.[field.name] === 'object') {
Expand All @@ -891,27 +913,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
tabSelect = select
}

const pathSegments = path ? path.split('.') : []

// If passesCondition is false then this should always result to false
// If the tab has no admin.condition provided then fallback to passesCondition and let that decide the result
let tabPassesCondition = passesCondition

if (passesCondition && typeof field.admin?.condition === 'function') {
tabPassesCondition = field.admin.condition(fullData, data, {
blockData,
operation,
path: pathSegments,
user: req.user,
})
}

if (field?.id) {
state[field.id] = {
passesCondition: tabPassesCondition,
}
}

return iterateFields({
id,
addErrorPathToParent: addErrorPathToParentArg,
Expand All @@ -930,7 +931,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
omitParents,
operation,
parentIndexPath: indexPath,
parentPassesCondition: tabPassesCondition,
parentPassesCondition: passesCondition,
parentPath: path,
parentSchemaPath: schemaPath,
permissions: childPermissions,
Expand All @@ -951,10 +952,6 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
state[path] = {
disableFormData: true,
}

if (passesCondition === false) {
state[path].passesCondition = false
}
}

return iterateFields({
Expand Down
112 changes: 103 additions & 9 deletions test/fields/payload-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export type SupportedTimezones =
export interface Config {
auth: {
users: UserAuthOperations;
'payload-mcp-api-keys': PayloadMcpApiKeyAuthOperations;
};
blocks: {
ConfigBlockTest: ConfigBlockTest;
Expand Down Expand Up @@ -109,6 +110,7 @@ export interface Config {
'uploads-multi-poly': UploadsMultiPoly;
'uploads-restricted': UploadsRestricted;
'ui-fields': UiField;
'payload-mcp-api-keys': PayloadMcpApiKey;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
Expand Down Expand Up @@ -152,6 +154,7 @@ export interface Config {
'uploads-multi-poly': UploadsMultiPolySelect<false> | UploadsMultiPolySelect<true>;
'uploads-restricted': UploadsRestrictedSelect<false> | UploadsRestrictedSelect<true>;
'ui-fields': UiFieldsSelect<false> | UiFieldsSelect<true>;
'payload-mcp-api-keys': PayloadMcpApiKeysSelect<false> | PayloadMcpApiKeysSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
Expand All @@ -167,7 +170,7 @@ export interface Config {
widgets: {
collections: CollectionsWidget;
};
user: User;
user: User | PayloadMcpApiKey;
jobs: {
tasks: unknown;
workflows: unknown;
Expand All @@ -191,6 +194,24 @@ export interface UserAuthOperations {
password: string;
};
}
export interface PayloadMcpApiKeyAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "ConfigBlockTest".
Expand Down Expand Up @@ -1911,6 +1932,49 @@ export interface UiField {
updatedAt: string;
createdAt: string;
}
/**
* API keys control which collections, resources, tools, and prompts MCP clients can access
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-mcp-api-keys".
*/
export interface PayloadMcpApiKey {
id: string;
/**
* The user that the API key is associated with.
*/
user: string | User;
/**
* A useful label for the API key.
*/
label?: string | null;
/**
* The purpose of the API key.
*/
description?: string | null;
/**
* When checked, this key bypasses Payload access control on every operation it performs. Leave unchecked unless you have a specific reason.
*/
overrideAccess?: boolean | null;
/**
* Access for this API key — uncheck to revoke individual tools.
*/
access?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
enableAPIKey?: boolean | null;
apiKey?: string | null;
apiKeyIndex?: string | null;
collection: 'payload-mcp-api-keys';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
Expand Down Expand Up @@ -2078,12 +2142,21 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'ui-fields';
value: string | UiField;
} | null)
| ({
relationTo: 'payload-mcp-api-keys';
value: string | PayloadMcpApiKey;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
user:
| {
relationTo: 'users';
value: string | User;
}
| {
relationTo: 'payload-mcp-api-keys';
value: string | PayloadMcpApiKey;
};
updatedAt: string;
createdAt: string;
}
Expand All @@ -2093,10 +2166,15 @@ export interface PayloadLockedDocument {
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
user:
| {
relationTo: 'users';
value: string | User;
}
| {
relationTo: 'payload-mcp-api-keys';
value: string | PayloadMcpApiKey;
};
key?: string | null;
value?:
| {
Expand Down Expand Up @@ -3704,6 +3782,22 @@ export interface UiFieldsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-mcp-api-keys_select".
*/
export interface PayloadMcpApiKeysSelect<T extends boolean = true> {
user?: T;
label?: T;
description?: T;
overrideAccess?: T;
access?: T;
updatedAt?: T;
createdAt?: T;
enableAPIKey?: T;
apiKey?: T;
apiKeyIndex?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
Expand Down
13 changes: 13 additions & 0 deletions test/form-state/collections/Conditions/CustomField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { TextFieldServerComponent } from 'payload'

import { TextField } from '@payloadcms/ui'

export const CustomTextField: TextFieldServerComponent = ({
clientField,
path,
payload,
schemaPath,
}) => {
payload.logger.info('RENDERED CUSTOM SERVER COMPONENT')
return <TextField field={clientField} path={path} schemaPath={schemaPath} />
}
23 changes: 23 additions & 0 deletions test/form-state/collections/Conditions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { CollectionConfig } from 'payload'

export const conditionsSlug = 'conditions'

export const ConditionsCollection: CollectionConfig = {
slug: conditionsSlug,
fields: [
{
name: 'showField',
type: 'checkbox',
},
{
name: 'conditionalCustomField',
type: 'text',
admin: {
condition: (data) => data?.showField === true,
components: {
Field: './collections/Conditions/CustomField.js#CustomTextField',
},
},
},
],
}
3 changes: 2 additions & 1 deletion test/form-state/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { AutosavePostsCollection } from './collections/Autosave/index.js'
import { ConditionsCollection } from './collections/Conditions/index.js'
import { PostsCollection, postsSlug } from './collections/Posts/index.js'

const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)

export default buildConfigWithDefaults({
collections: [PostsCollection, AutosavePostsCollection],
collections: [PostsCollection, AutosavePostsCollection, ConditionsCollection],
admin: {
importMap: {
baseDir: path.resolve(dirname),
Expand Down
Loading
Loading