Compare commits

..

1 Commits

Author SHA1 Message Date
German Jablonski
a44857a2ee cannot reproduce in website template 2025-08-22 15:41:28 +01:00
35 changed files with 301 additions and 1506 deletions

View File

@@ -81,7 +81,7 @@ To install a Database Adapter, you can run **one** of the following commands:
#### 2. Copy Payload files into your Next.js app folder
Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/%28payload%29) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this:
Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](<https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)>) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this:
```plaintext
app/

View File

@@ -37,12 +37,11 @@ import {
GraphQLString,
} from 'graphql'
import { flattenTopLevelFields, toWords } from 'payload'
import { fieldAffectsData, tabHasName } from 'payload/shared'
import { fieldAffectsData, optionIsObject, tabHasName } from 'payload/shared'
import { GraphQLJSON } from '../packages/graphql-type-json/index.js'
import { combineParentName } from '../utilities/combineParentName.js'
import { formatName } from '../utilities/formatName.js'
import { formatOptions } from '../utilities/formatOptions.js'
import { groupOrTabHasRequiredSubfield } from '../utilities/groupOrTabHasRequiredSubfield.js'
import { withNullableType } from './withNullableType.js'
@@ -210,18 +209,12 @@ export function buildMutationInputType({
}),
},
}),
radio: (inputObjectTypeConfig: InputObjectTypeConfig, field: RadioField) => {
const type = new GraphQLEnumType({
name: `${combineParentName(parentName, field.name)}_MutationInput`,
values: formatOptions(field),
})
return {
...inputObjectTypeConfig,
[formatName(field.name)]: {
type: withNullableType({ type, field, forceNullable, parentIsLocalized }),
},
}
},
radio: (inputObjectTypeConfig: InputObjectTypeConfig, field: RadioField) => ({
...inputObjectTypeConfig,
[formatName(field.name)]: {
type: withNullableType({ type: GraphQLString, field, forceNullable, parentIsLocalized }),
},
}),
relationship: (inputObjectTypeConfig: InputObjectTypeConfig, field: RelationshipField) => {
const { relationTo } = field
type PayloadGraphQLRelationshipType =
@@ -285,7 +278,23 @@ export function buildMutationInputType({
const formattedName = `${combineParentName(parentName, field.name)}_MutationInput`
let type: GraphQLType = new GraphQLEnumType({
name: formattedName,
values: formatOptions(field),
values: field.options.reduce((values, option) => {
if (optionIsObject(option)) {
return {
...values,
[formatName(option.value)]: {
value: option.value,
},
}
}
return {
...values,
[formatName(option)]: {
value: option,
},
}
}, {}),
})
type = field.hasMany ? new GraphQLList(type) : type

View File

@@ -11,10 +11,11 @@ import {
GraphQLString,
} from 'graphql'
import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'
import { optionIsObject } from 'payload/shared'
import { GraphQLJSON } from '../packages/graphql-type-json/index.js'
import { combineParentName } from '../utilities/combineParentName.js'
import { formatOptions } from '../utilities/formatOptions.js'
import { formatName } from '../utilities/formatName.js'
import { operators } from './operators.js'
type staticTypes =
@@ -146,7 +147,23 @@ const defaults: DefaultsType = {
type: (field: RadioField, parentName): GraphQLType =>
new GraphQLEnumType({
name: `${combineParentName(parentName, field.name)}_Input`,
values: formatOptions(field),
values: field.options.reduce((values, option) => {
if (optionIsObject(option)) {
return {
...values,
[formatName(option.value)]: {
value: option.value,
},
}
}
return {
...values,
[formatName(option)]: {
value: option,
},
}
}, {}),
}),
})),
],
@@ -174,7 +191,23 @@ const defaults: DefaultsType = {
type: (field: SelectField, parentName): GraphQLType =>
new GraphQLEnumType({
name: `${combineParentName(parentName, field.name)}_Input`,
values: formatOptions(field),
values: field.options.reduce((values, option) => {
if (optionIsObject(option)) {
return {
...values,
[formatName(option.value)]: {
value: option.value,
},
}
}
return {
...values,
[formatName(option)]: {
value: option,
},
}
}, {}),
}),
})),
],

View File

@@ -1,56 +1,23 @@
import type { GraphQLEnumValueConfigMap } from 'graphql'
import type { RadioField, SelectField } from 'payload'
import { formatName } from './formatName.js'
/**
* Convert an arbitrary string into a valid GraphQL enum *name* (token).
* Keeps formatNames backwards-compat behavior, then enforces GraphQL rules.
*/
const sanitizeEnumName = (value: string) => {
let key = formatName(value)
.replace(/\W/g, '_') // non-word chars → underscore
.replace(/_+/g, '_') // collapse repeated underscores
.replace(/^_+|_+$/g, '') // trim leading/trailing underscores
// GraphQL names must start with a letter or underscore
if (!/^[A-Z_]/i.test(key)) {
key = `_${key}`
}
return key || '_'
}
/**
* Build a GraphQL enum value map from select/radio options.
* - Keys: safe GraphQL enum names
* - Values: original option values (string/number/boolean)
* - Deterministically disambiguates collisions by suffixing _2, _3, ...
*/
export const formatOptions = (field: RadioField | SelectField): GraphQLEnumValueConfigMap => {
const enumValueMap: GraphQLEnumValueConfigMap = {}
const nameCounts = new Map<string, number>()
const optionsArray = Array.isArray(field.options) ? field.options : []
for (const option of optionsArray) {
const rawValue = typeof option === 'object' ? String(option.value) : String(option)
let enumName = sanitizeEnumName(rawValue)
// De-duplicate if multiple raw values sanitize to the same enum name
const nextCount = (nameCounts.get(enumName) ?? 0) + 1
nameCounts.set(enumName, nextCount)
if (nextCount > 1) {
enumName = `${enumName}_${nextCount}`
export const formatOptions = (field: RadioField | SelectField) => {
return field.options.reduce((values, option) => {
if (typeof option === 'object') {
return {
...values,
[formatName(option.value)]: {
value: option.value,
},
}
}
enumValueMap[enumName] = {
value: typeof option === 'object' ? option.value : option,
return {
...values,
[formatName(option)]: {
value: option,
},
}
}
return enumValueMap
}, {})
}

View File

@@ -208,7 +208,7 @@ export const renderDocument = async ({
globalSlug,
locale: locale?.code,
operation,
readOnly: isTrashedDoc || isLocked,
readOnly: isTrashedDoc,
renderAllFields: true,
req,
schemaPath: collectionSlug || globalSlug,

View File

@@ -11,7 +11,6 @@ export type Data = {
}
export type Row = {
addedByServer?: FieldState['addedByServer']
blockType?: string
collapsed?: boolean
customComponents?: {

View File

@@ -35,10 +35,6 @@ export type JobStats = {
export const getJobStatsGlobal: (config: Config) => GlobalConfig = (config) => {
return {
slug: jobStatsGlobalSlug,
admin: {
group: 'System',
hidden: true,
},
fields: [
{
name: 'stats',

View File

@@ -9,7 +9,14 @@ type Props = {
label: MultiTenantPluginConfig['tenantSelectorLabel']
} & ServerProps
export const TenantSelector = (props: Props) => {
const { label, viewType } = props
const { enabledSlugs, label, params, viewType } = props
const enabled = Boolean(
params?.segments &&
Array.isArray(params.segments) &&
params.segments[0] === 'collections' &&
params.segments[1] &&
enabledSlugs.includes(params.segments[1]),
)
return <TenantSelectorClient label={label} viewType={viewType} />
return <TenantSelectorClient disabled={!enabled} label={label} viewType={viewType} />
}

View File

@@ -25,7 +25,7 @@ type Args = {
view: ViewTypes
}
export async function getGlobalViewRedirect({
slug: collectionSlug,
slug,
basePath,
docID,
headers,
@@ -64,67 +64,45 @@ export async function getGlobalViewRedirect({
tenant = tenantOptions[0]?.value || null
}
if (tenant) {
try {
const globalTenantDocQuery = await payload.find({
collection: collectionSlug,
depth: 0,
limit: 1,
pagination: false,
select: {
id: true,
try {
const { docs } = await payload.find({
collection: slug,
depth: 0,
limit: 1,
overrideAccess: false,
pagination: false,
user,
where: {
[tenantFieldName]: {
equals: tenant,
},
where: {
[tenantFieldName]: {
equals: tenant,
},
},
})
const globalTenantDocID = globalTenantDocQuery?.docs?.[0]?.id
if (view === 'document') {
// global tenant document edit view
if (globalTenantDocID && docID !== globalTenantDocID) {
// tenant document already exists but does not match current route docID
// redirect to matching tenant docID from query
redirectRoute = `/collections/${collectionSlug}/${globalTenantDocID}`
} else if (docID && !globalTenantDocID) {
// a docID was found in the route but no global document with this tenant exists
// so we need to generate a redirect to the create route
redirectRoute = await generateCreateRedirect({
collectionSlug,
payload,
tenantID: tenant,
})
}
} else if (view === 'list') {
// global tenant document list view
if (globalTenantDocID) {
// tenant document exists, redirect from list view to the document edit view
redirectRoute = `/collections/${collectionSlug}/${globalTenantDocID}`
} else {
// no matching document was found for the current tenant
// so we need to generate a redirect to the create route
redirectRoute = await generateCreateRedirect({
collectionSlug,
payload,
tenantID: tenant,
})
}
}
} catch (e: unknown) {
const prefix = `${e && typeof e === 'object' && 'message' in e && typeof e.message === 'string' ? `${e.message} - ` : ''}`
payload.logger.error(e, `${prefix}Multi Tenant Redirect Error`)
}
} else {
// no tenants were found, redirect to the admin view
return formatAdminURL({
adminRoute: payload.config.routes.admin,
basePath,
path: '',
serverURL: payload.config.serverURL,
},
})
const tenantDocID = docs?.[0]?.id
if (view === 'document') {
if (docID && !tenantDocID) {
// viewing a document with an id but does not match the selected tenant, redirect to create route
redirectRoute = `/collections/${slug}/create`
} else if (tenantDocID && docID !== tenantDocID) {
// tenant document already exists but does not match current route doc ID, redirect to matching tenant doc
redirectRoute = `/collections/${slug}/${tenantDocID}`
}
} else if (view === 'list') {
if (tenantDocID) {
// tenant document exists, redirect to edit view
redirectRoute = `/collections/${slug}/${tenantDocID}`
} else {
// tenant document does not exist, redirect to create route
redirectRoute = `/collections/${slug}/create`
}
}
} catch (e: unknown) {
payload.logger.error(
e,
`${typeof e === 'object' && e && 'message' in e ? `e?.message - ` : ''}Multi Tenant Redirect Error`,
)
}
if (redirectRoute) {
@@ -136,57 +114,5 @@ export async function getGlobalViewRedirect({
})
}
// no redirect is needed
// the current route is valid
return undefined
}
type GenerateCreateArgs = {
collectionSlug: string
payload: Payload
tenantID: number | string
}
/**
* Generate a redirect URL for creating a new document in a multi-tenant collection.
*
* If autosave is enabled on the collection, we need to create the document and then redirect to it.
* Otherwise we can redirect to the default create route.
*/
async function generateCreateRedirect({
collectionSlug,
payload,
tenantID,
}: GenerateCreateArgs): Promise<`/${string}` | undefined> {
const collection = payload.collections[collectionSlug]
if (
collection?.config.versions?.drafts &&
typeof collection.config.versions.drafts === 'object' &&
collection.config.versions.drafts.autosave
) {
// Autosave is enabled, create a document first
try {
const doc = await payload.create({
collection: collectionSlug,
data: {
tenant: tenantID,
},
depth: 0,
draft: true,
select: {
id: true,
},
})
return `/collections/${collectionSlug}/${doc.id}`
} catch (error) {
payload.logger.error(
error,
`Error creating autosave global multi tenant document for ${collectionSlug}`,
)
}
return '/'
}
// Autosave is not enabled, redirect to default create route
return `/collections/${collectionSlug}/create`
}

View File

@@ -35,7 +35,7 @@ export const getTenantOptions = async ({
overrideAccess: false,
select: {
[useAsTitle]: true,
...(isOrderable && { _order: true }),
...(isOrderable ? { _order: true } : {}),
},
sort: isOrderable ? '_order' : useAsTitle,
user,

View File

@@ -89,38 +89,30 @@ export const mergeServerFormState = ({
}
/**
* Deeply merge the rows array to ensure changes to local state are not lost while the request was pending
* Intelligently merge the rows array to ensure changes to local state are not lost while the request was pending
* For example, the server response could come back with a row which has been deleted on the client
* Loop over the incoming rows, if it exists in client side form state, merge in any new properties from the server
* Note: read `currentState` and not `newState` here, as the `rows` property have already been merged above
*/
if (Array.isArray(incomingField.rows) && path in currentState) {
newState[path].rows = [...(currentState[path]?.rows || [])] // shallow copy to avoid mutating the original array
if (Array.isArray(incomingField.rows)) {
if (acceptValues === true) {
newState[path].rows = incomingField.rows
} else if (path in currentState) {
newState[path].rows = [...(currentState[path]?.rows || [])] // shallow copy to avoid mutating the original array
incomingField.rows.forEach((row) => {
const indexInCurrentState = currentState[path].rows?.findIndex(
(existingRow) => existingRow.id === row.id,
)
incomingField.rows.forEach((row) => {
const indexInCurrentState = currentState[path].rows?.findIndex(
(existingRow) => existingRow.id === row.id,
)
if (indexInCurrentState > -1) {
newState[path].rows[indexInCurrentState] = {
...currentState[path].rows[indexInCurrentState],
...row,
if (indexInCurrentState > -1) {
newState[path].rows[indexInCurrentState] = {
...currentState[path].rows[indexInCurrentState],
...row,
}
}
} else if (row.addedByServer) {
/**
* Note: This is a known limitation of computed array and block rows
* If a new row was added by the server, we append it to the _end_ of this array
* This is because the client is the source of truth, and it has arrays ordered in a certain position
* For example, the user may have re-ordered rows client-side while a long running request is processing
* This means that we _cannot_ slice a new row into the second position on the server, for example
* By the time it gets back to the client, its index is stale
*/
const newRow = { ...row }
delete newRow.addedByServer
newState[path].rows.push(newRow)
}
})
})
}
}
// If `valid` is `undefined`, mark it as `true`

View File

@@ -352,10 +352,7 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
newRow.lastRenderedPath = previousRow.lastRenderedPath
}
// add addedByServer flag
if (!previousRow) {
newRow.addedByServer = true
}
acc.rows.push(newRow)
const isCollapsed = isRowCollapsed({
collapsedPrefs: preferences?.fields?.[path]?.collapsed,
@@ -365,11 +362,9 @@ export const addFieldStatePromise = async (args: AddFieldStatePromiseArgs): Prom
})
if (isCollapsed) {
newRow.collapsed = true
acc.rows[acc.rows.length - 1].collapsed = true
}
acc.rows.push(newRow)
return acc
},
{

View File

@@ -168,16 +168,12 @@ const DocumentInfo: React.FC<
try {
const isGlobal = slug === globalSlug
const request = await requests.get(`${serverURL}${api}/payload-locked-documents`, {
const query = isGlobal
? `where[globalSlug][equals]=${slug}`
: `where[document.value][equals]=${docID}&where[document.relationTo][equals]=${slug}`
const request = await requests.get(`${serverURL}${api}/payload-locked-documents?${query}`, {
credentials: 'include',
params: isGlobal
? {
'where[globalSlug][equals]': slug,
}
: {
'where[document.relationTo][equals]': slug,
'where[document.value][equals]': docID,
},
})
const { docs } = await request.json()
@@ -205,17 +201,13 @@ const DocumentInfo: React.FC<
try {
const isGlobal = slug === globalSlug
const query = isGlobal
? `where[globalSlug][equals]=${slug}`
: `where[document.value][equals]=${docID}&where[document.relationTo][equals]=${slug}`
// Check if the document is already locked
const request = await requests.get(`${serverURL}${api}/payload-locked-documents`, {
const request = await requests.get(`${serverURL}${api}/payload-locked-documents?${query}`, {
credentials: 'include',
params: isGlobal
? {
'where[globalSlug][equals]': slug,
}
: {
'where[document.relationTo][equals]': slug,
'where[document.value][equals]': docID,
},
})
const { docs } = await request.json()

View File

@@ -191,11 +191,7 @@ export const Auth: React.FC<Props> = (props) => {
}
}, [modified])
const showAuthBlock = enableFields
const showAPIKeyBlock = useAPIKey && canReadApiKey
const showVerifyBlock = verify && isEditing
if (!(showAuthBlock || showAPIKeyBlock || showVerifyBlock)) {
if (disableLocalStrategy && !enableFields && !useAPIKey) {
return null
}

View File

@@ -47,6 +47,8 @@ export default async function Post({ params: paramsPromise }: Args) {
const url = '/posts/' + slug
const post = await queryPostBySlug({ slug })
console.log('post', post)
if (!post) return <PayloadRedirects url={url} />
return (
@@ -91,6 +93,7 @@ const queryPostBySlug = cache(async ({ slug }: { slug: string }) => {
collection: 'posts',
draft,
limit: 1,
// depth: 0, // IF YOU UNCOMMENT THIS, THE BLOCKS WILL NOT BE POPULATED
overrideAccess: draft,
pagination: false,
where: {

View File

@@ -0,0 +1,13 @@
import type { Block } from 'payload'
export const RelationshipBlock: Block = {
slug: 'relationshipBlock',
interfaceName: 'RelationshipBlock',
fields: [
{
name: 'relationship',
type: 'relationship',
relationTo: 'posts',
},
],
}

View File

@@ -26,6 +26,7 @@ import {
PreviewField,
} from '@payloadcms/plugin-seo/fields'
import { slugField } from '@/fields/slug'
import { RelationshipBlock } from '@/blocks/Relationship/config'
export const Posts: CollectionConfig<'posts'> = {
slug: 'posts',
@@ -92,7 +93,9 @@ export const Posts: CollectionConfig<'posts'> = {
return [
...rootFeatures,
HeadingFeature({ enabledHeadingSizes: ['h1', 'h2', 'h3', 'h4'] }),
BlocksFeature({ blocks: [Banner, Code, MediaBlock] }),
BlocksFeature({
blocks: [Banner, Code, MediaBlock, RelationshipBlock],
}),
FixedToolbarFeature(),
InlineToolbarFeature(),
HorizontalRuleFeature(),

View File

@@ -17,6 +17,7 @@ import type {
BannerBlock as BannerBlockProps,
CallToActionBlock as CTABlockProps,
MediaBlock as MediaBlockProps,
RelationshipBlock,
} from '@/payload-types'
import { BannerBlock } from '@/blocks/Banner/Component'
import { CallToActionBlock } from '@/blocks/CallToAction/Component'
@@ -24,7 +25,9 @@ import { cn } from '@/utilities/ui'
type NodeTypes =
| DefaultNodeTypes
| SerializedBlockNode<CTABlockProps | MediaBlockProps | BannerBlockProps | CodeBlockProps>
| SerializedBlockNode<
CTABlockProps | MediaBlockProps | BannerBlockProps | CodeBlockProps | RelationshipBlock
>
const internalDocToHref = ({ linkNode }: { linkNode: SerializedLinkNode }) => {
const { value, relationTo } = linkNode.fields.doc!
@@ -52,6 +55,13 @@ const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters })
),
code: ({ node }) => <CodeBlock className="col-start-2" {...node.fields} />,
cta: ({ node }) => <CallToActionBlock {...node.fields} />,
relationshipBlock: ({ node }) => {
console.log('relationshipBlock', node)
const relationship = node.fields.relationship
if (typeof relationship !== 'object')
return <pre>{JSON.stringify(relationship, null, 2)}</pre>
return <pre>{JSON.stringify(relationship?.title, null, 2)}</pre>
},
},
})

View File

@@ -1708,6 +1708,16 @@ export interface CodeBlock {
blockName?: string | null;
blockType: 'code';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "RelationshipBlock".
*/
export interface RelationshipBlock {
relationship?: (string | null) | Post;
id?: string | null;
blockName?: string | null;
blockType: 'relationshipBlock';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".

View File

@@ -261,33 +261,6 @@ export default buildConfigWithDefaults({
},
],
},
{
slug: 'api-keys-with-field-read-access',
auth: {
disableLocalStrategy: true,
useAPIKey: true,
},
fields: [
{
name: 'enableAPIKey',
type: 'checkbox',
access: {
read: () => false,
},
},
{
name: 'apiKey',
type: 'text',
access: {
read: () => false,
},
},
],
labels: {
plural: 'API Keys With Field Read Access',
singular: 'API Key With Field Read Access',
},
},
],
onInit: seed,
typescript: {

View File

@@ -335,30 +335,5 @@ describe('Auth', () => {
})
})
})
describe('api-keys-with-field-read-access', () => {
let user
beforeAll(async () => {
url = new AdminUrlUtil(serverURL, 'api-keys-with-field-read-access')
user = await payload.create({
collection: apiKeysSlug,
data: {
apiKey: uuid(),
enableAPIKey: true,
},
})
})
test('should hide auth parent container if api keys enabled but no read access', async () => {
await page.goto(url.create)
// assert that the auth parent container is hidden
await expect(page.locator('.auth-fields')).toBeHidden()
await saveDocAndAssert(page)
})
})
})
})

View File

@@ -68,7 +68,6 @@ export interface Config {
'disable-local-strategy-password': DisableLocalStrategyPasswordAuthOperations;
'api-keys': ApiKeyAuthOperations;
'public-users': PublicUserAuthOperations;
'api-keys-with-field-read-access': ApiKeysWithFieldReadAccessAuthOperations;
};
blocks: {};
collections: {
@@ -78,7 +77,6 @@ export interface Config {
'api-keys': ApiKey;
'public-users': PublicUser;
relationsCollection: RelationsCollection;
'api-keys-with-field-read-access': ApiKeysWithFieldReadAccess;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -91,7 +89,6 @@ export interface Config {
'api-keys': ApiKeysSelect<false> | ApiKeysSelect<true>;
'public-users': PublicUsersSelect<false> | PublicUsersSelect<true>;
relationsCollection: RelationsCollectionSelect<false> | RelationsCollectionSelect<true>;
'api-keys-with-field-read-access': ApiKeysWithFieldReadAccessSelect<false> | ApiKeysWithFieldReadAccessSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -117,9 +114,6 @@ export interface Config {
})
| (PublicUser & {
collection: 'public-users';
})
| (ApiKeysWithFieldReadAccess & {
collection: 'api-keys-with-field-read-access';
});
jobs: {
tasks: unknown;
@@ -216,24 +210,6 @@ export interface PublicUserAuthOperations {
password: string;
};
}
export interface ApiKeysWithFieldReadAccessAuthOperations {
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` "users".
@@ -364,18 +340,6 @@ export interface RelationsCollection {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "api-keys-with-field-read-access".
*/
export interface ApiKeysWithFieldReadAccess {
id: string;
updatedAt: string;
createdAt: string;
enableAPIKey?: boolean | null;
apiKey?: string | null;
apiKeyIndex?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -406,10 +370,6 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'relationsCollection';
value: string | RelationsCollection;
} | null)
| ({
relationTo: 'api-keys-with-field-read-access';
value: string | ApiKeysWithFieldReadAccess;
} | null);
globalSlug?: string | null;
user:
@@ -432,10 +392,6 @@ export interface PayloadLockedDocument {
| {
relationTo: 'public-users';
value: string | PublicUser;
}
| {
relationTo: 'api-keys-with-field-read-access';
value: string | ApiKeysWithFieldReadAccess;
};
updatedAt: string;
createdAt: string;
@@ -466,10 +422,6 @@ export interface PayloadPreference {
| {
relationTo: 'public-users';
value: string | PublicUser;
}
| {
relationTo: 'api-keys-with-field-read-access';
value: string | ApiKeysWithFieldReadAccess;
};
key?: string | null;
value?:
@@ -624,17 +576,6 @@ export interface RelationsCollectionSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "api-keys-with-field-read-access_select".
*/
export interface ApiKeysWithFieldReadAccessSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
enableAPIKey?: T;
apiKey?: T;
apiKeyIndex?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -1,5 +1,5 @@
import React from 'react'
export const ArrayRowLabel = () => {
return <p id="custom-array-row-label">This is a custom component</p>
return <p>This is a custom component</p>
}

View File

@@ -1,5 +1,4 @@
import type { FieldState, FormState, Payload, User } from 'payload'
import type React from 'react'
import { buildFormState } from '@payloadcms/ui/utilities/buildFormState'
import path from 'path'
@@ -11,7 +10,6 @@ import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { devUser } from '../credentials.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
import { postsSlug } from './collections/Posts/index.js'
// eslint-disable-next-line payload/no-relative-monorepo-imports
import { mergeServerFormState } from '../../packages/ui/src/forms/Form/mergeServerFormState.js'
@@ -23,14 +21,6 @@ const { email, password } = devUser
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const DummyReactComponent: React.ReactNode = {
// @ts-expect-error - can ignore, needs to satisfy `typeof value.$$typeof === 'symbol'`
$$typeof: Symbol.for('react.element'),
type: 'div',
props: {},
key: null,
}
describe('Form State', () => {
beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname, undefined, true))
@@ -576,7 +566,7 @@ describe('Form State', () => {
expect(newState === currentState).toBe(true)
})
it('should accept all values from the server regardless of local modifications, e.g. `acceptAllValues` on submit', () => {
it('should accept all values from the server regardless of local modifications, e.g. on submit', () => {
const title: FieldState = {
value: 'Test Post (modified on the client)',
initialValue: 'Test Post',
@@ -587,7 +577,7 @@ describe('Form State', () => {
const currentState: Record<string, FieldState> = {
title: {
...title,
isModified: true, // This is critical, this is what we're testing
isModified: true,
},
computedTitle: {
value: 'Test Post (computed on the client)',
@@ -595,31 +585,6 @@ describe('Form State', () => {
valid: true,
passesCondition: true,
},
array: {
rows: [
{
id: '1',
customComponents: {
RowLabel: DummyReactComponent,
},
lastRenderedPath: 'array.0.customTextField',
},
],
valid: true,
passesCondition: true,
},
'array.0.id': {
value: '1',
initialValue: '1',
valid: true,
passesCondition: true,
},
'array.0.customTextField': {
value: 'Test Post (modified on the client)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const incomingStateFromServer: Record<string, FieldState> = {
@@ -635,29 +600,6 @@ describe('Form State', () => {
valid: true,
passesCondition: true,
},
array: {
rows: [
{
id: '1',
lastRenderedPath: 'array.0.customTextField',
// Omit `customComponents` because the server did not re-render this row
},
],
passesCondition: true,
valid: true,
},
'array.0.id': {
value: '1',
initialValue: '1',
valid: true,
passesCondition: true,
},
'array.0.customTextField': {
value: 'Test Post (modified on the client)',
initialValue: 'Test Post',
valid: true,
passesCondition: true,
},
}
const newState = mergeServerFormState({
@@ -672,14 +614,10 @@ describe('Form State', () => {
...incomingStateFromServer.title,
isModified: true,
},
array: {
...incomingStateFromServer.array,
rows: currentState?.array?.rows,
},
})
})
it('should not accept values from the server if they have been modified locally since the request was made, e.g. `overrideLocalChanges: false` on autosave', () => {
it('should not accept values from the server if they have been modified locally since the request was made, e.g. on autosave', () => {
const title: FieldState = {
value: 'Test Post (modified on the client 1)',
initialValue: 'Test Post',

View File

@@ -145,19 +145,6 @@ export default buildConfigWithDefaults({
},
],
},
{
name: 'spaceBottom',
type: 'select',
required: false,
defaultValue: 'mb-0',
options: [
{ label: 'None', value: 'mb-0' },
{ label: 'Small', value: 'mb-8' },
{ label: 'Medium', value: 'mb-16' },
{ label: 'Large', value: 'mb-24' },
{ label: 'Extra Large', value: 'mb-[150px]' },
],
},
],
},
{

File diff suppressed because it is too large Load Diff

View File

@@ -96,26 +96,22 @@ async function selectOption({
type GetSelectInputValueFunction = <TMultiSelect = true>(args: {
multiSelect: TMultiSelect
selectLocator: Locator
valueLabelClass?: string
}) => Promise<TMultiSelect extends true ? string[] : string | undefined>
export const getSelectInputValue: GetSelectInputValueFunction = async ({
selectLocator,
multiSelect = false,
valueLabelClass,
}) => {
if (multiSelect) {
// For multi-select, get all selected options
const selectedOptions = await selectLocator
.locator(valueLabelClass || '.multi-value-label__text')
.locator('.multi-value-label__text')
.allTextContents()
return selectedOptions || []
}
// For single-select, get the selected value
const singleValue = await selectLocator
.locator(valueLabelClass || '.react-select--single-value')
.textContent()
const singleValue = await selectLocator.locator('.react-select--single-value').textContent()
return (singleValue ?? undefined) as any
}

View File

@@ -16,10 +16,6 @@ export const PostsCollection: CollectionConfig = {
name: 'text',
type: 'text',
},
{
name: 'richText',
type: 'richText',
},
{
name: 'documentLoaded',
label: 'Document loaded',

View File

@@ -755,22 +755,6 @@ describe('Locked Documents', () => {
// fields should be readOnly / disabled
await expect(page.locator('#field-text')).toBeDisabled()
const richTextRoot = page
.locator('.rich-text-lexical .ContentEditable__root[data-lexical-editor="true"]')
.first()
// ensure editor is present
await expect(richTextRoot).toBeVisible()
// core read-only checks
await expect(richTextRoot).toHaveAttribute('contenteditable', 'false')
await expect(richTextRoot).toHaveAttribute('aria-readonly', 'true')
// wrapper has read-only class (nice-to-have)
await expect(page.locator('.rich-text-lexical').first()).toHaveClass(
/rich-text-lexical--read-only/,
)
})
})

View File

@@ -141,21 +141,6 @@ export interface Page {
export interface Post {
id: string;
text?: string | null;
richText?: {
root: {
type: string;
children: {
type: string;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
documentLoaded?: string | null;
updatedAt: string;
createdAt: string;
@@ -279,7 +264,6 @@ export interface PagesSelect<T extends boolean = true> {
*/
export interface PostsSelect<T extends boolean = true> {
text?: T;
richText?: T;
documentLoaded?: T;
updatedAt?: T;
createdAt?: T;

View File

@@ -1,30 +0,0 @@
import type { CollectionConfig } from 'payload'
import { autosaveGlobalSlug } from '../shared.js'
export const AutosaveGlobal: CollectionConfig = {
slug: autosaveGlobalSlug,
admin: {
useAsTitle: 'title',
group: 'Tenant Globals',
},
fields: [
{
name: 'title',
label: 'Title',
type: 'text',
required: true,
},
{
name: 'description',
type: 'textarea',
},
],
versions: {
drafts: {
autosave: {
interval: 100,
},
},
},
}

View File

@@ -7,16 +7,15 @@ const dirname = path.dirname(filename)
import type { Config as ConfigType } from './payload-types.js'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { AutosaveGlobal } from './collections/AutosaveGlobal.js'
import { Menu } from './collections/Menu.js'
import { MenuItems } from './collections/MenuItems.js'
import { Tenants } from './collections/Tenants.js'
import { Users } from './collections/Users/index.js'
import { seed } from './seed/index.js'
import { autosaveGlobalSlug, menuItemsSlug, menuSlug } from './shared.js'
import { menuItemsSlug, menuSlug } from './shared.js'
export default buildConfigWithDefaults({
collections: [Tenants, Users, MenuItems, Menu, AutosaveGlobal],
collections: [Tenants, Users, MenuItems, Menu],
admin: {
autoLogin: false,
importMap: {
@@ -45,9 +44,6 @@ export default buildConfigWithDefaults({
[menuSlug]: {
isGlobal: true,
},
[autosaveGlobalSlug]: {
isGlobal: true,
},
},
i18n: {
translations: {

View File

@@ -28,7 +28,7 @@ import { reInitializeDB } from '../helpers/reInitializeDB.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { credentials } from './credentials.js'
import { seed } from './seed/index.js'
import { autosaveGlobalSlug, menuItemsSlug, menuSlug, tenantsSlug, usersSlug } from './shared.js'
import { menuItemsSlug, menuSlug, tenantsSlug, usersSlug } from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -37,7 +37,6 @@ test.describe('Multi Tenant', () => {
let page: Page
let serverURL: string
let globalMenuURL: AdminUrlUtil
let autosaveGlobalURL: AdminUrlUtil
let menuItemsURL: AdminUrlUtil
let usersURL: AdminUrlUtil
let tenantsURL: AdminUrlUtil
@@ -51,7 +50,6 @@ test.describe('Multi Tenant', () => {
menuItemsURL = new AdminUrlUtil(serverURL, menuItemsSlug)
usersURL = new AdminUrlUtil(serverURL, usersSlug)
tenantsURL = new AdminUrlUtil(serverURL, tenantsSlug)
autosaveGlobalURL = new AdminUrlUtil(serverURL, autosaveGlobalSlug)
const context = await browser.newContext()
page = await context.newPage()
@@ -76,7 +74,7 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await clearGlobalTenant({ page })
await clearTenant({ page })
await page.goto(tenantsURL.list)
@@ -131,7 +129,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(menuItemsURL.list)
await clearGlobalTenant({ page })
await clearTenant({ page })
await expect(
page.locator('.collection-list .table .cell-name', {
@@ -176,7 +174,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(menuItemsURL.list)
await clearGlobalTenant({ page })
await clearTenant({ page })
await expect(
page.locator('.collection-list .table .cell-name', {
@@ -192,7 +190,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(menuItemsURL.list)
await clearGlobalTenant({ page })
await clearTenant({ page })
await expect(
page.locator('.collection-list .table .cell-name', {
@@ -211,7 +209,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(usersURL.list)
await clearGlobalTenant({ page })
await clearTenant({ page })
await expect(
page.locator('.collection-list .table .cell-email', {
@@ -271,7 +269,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(menuItemsURL.list)
await clearGlobalTenant({ page })
await clearTenant({ page })
await goToListDoc({
page,
@@ -299,7 +297,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(menuItemsURL.list)
await clearGlobalTenant({ page })
await clearTenant({ page })
await goToListDoc({
page,
@@ -308,7 +306,7 @@ test.describe('Multi Tenant', () => {
urlUtil: menuItemsURL,
})
await selectDocumentTenant({
await selecteDocumentTenant({
page,
tenant: 'Steel Cat',
})
@@ -326,7 +324,7 @@ test.describe('Multi Tenant', () => {
data: credentials.admin,
})
await page.goto(menuItemsURL.create)
await selectDocumentTenant({
await selecteDocumentTenant({
page,
tenant: 'Blue Dog',
})
@@ -420,24 +418,6 @@ test.describe('Multi Tenant', () => {
})
.toBe('Steel Cat')
})
test('should navigate to globals with autosave enabled', async () => {
await loginClientSide({
page,
serverURL,
data: credentials.admin,
})
await page.goto(tenantsURL.list)
await clearGlobalTenant({ page })
await page.goto(autosaveGlobalURL.list)
await expect(page.locator('.doc-header__title')).toBeVisible()
const globalTenant = await getGlobalTenant({ page })
await expect
.poll(async () => {
return await getDocumentTenant({ page })
})
.toBe(globalTenant)
})
})
test.describe('Tenant Selector', () => {
@@ -519,7 +499,7 @@ test.describe('Multi Tenant', () => {
})
await page.goto(tenantsURL.list)
await clearGlobalTenant({ page })
await clearTenant({ page })
await expect(
page.locator('.collection-list .table .cell-name', {
@@ -606,24 +586,6 @@ test.describe('Multi Tenant', () => {
/**
* Helper Functions
*/
async function getGlobalTenant({ page }: { page: Page }): Promise<string | undefined> {
await openNav(page)
return await getSelectInputValue<false>({
selectLocator: page.locator('.tenant-selector'),
multiSelect: false,
})
}
async function getDocumentTenant({ page }: { page: Page }): Promise<string | undefined> {
await openNav(page)
return await getSelectInputValue<false>({
selectLocator: page.locator('#field-tenant'),
multiSelect: false,
valueLabelClass: '.relationship--single-value',
})
}
async function getTenantOptions({ page }: { page: Page }): Promise<string[]> {
await openNav(page)
return await getSelectInputOptions({
@@ -631,7 +593,7 @@ async function getTenantOptions({ page }: { page: Page }): Promise<string[]> {
})
}
async function selectDocumentTenant({
async function selecteDocumentTenant({
page,
tenant,
}: {
@@ -655,7 +617,7 @@ async function selectTenant({ page, tenant }: { page: Page; tenant: string }): P
})
}
async function clearGlobalTenant({ page }: { page: Page }): Promise<void> {
async function clearTenant({ page }: { page: Page }): Promise<void> {
await openNav(page)
return clearSelectInput({
selectLocator: page.locator('.tenant-selector'),

View File

@@ -71,7 +71,6 @@ export interface Config {
users: User;
'food-items': FoodItem;
'food-menu': FoodMenu;
'autosave-global': AutosaveGlobal;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -86,7 +85,6 @@ export interface Config {
users: UsersSelect<false> | UsersSelect<true>;
'food-items': FoodItemsSelect<false> | FoodItemsSelect<true>;
'food-menu': FoodMenuSelect<false> | FoodMenuSelect<true>;
'autosave-global': AutosaveGlobalSelect<false> | AutosaveGlobalSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -177,7 +175,7 @@ export interface User {
*/
export interface FoodItem {
id: string;
tenant?: (string | null) | Tenant;
tenant: string | Tenant;
name: string;
content?: {
root: {
@@ -203,7 +201,7 @@ export interface FoodItem {
*/
export interface FoodMenu {
id: string;
tenant?: (string | null) | Tenant;
tenant: string | Tenant;
title: string;
description?: string | null;
menuItems?:
@@ -219,19 +217,6 @@ export interface FoodMenu {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-global".
*/
export interface AutosaveGlobal {
id: string;
tenant?: (string | null) | Tenant;
title: string;
description?: string | null;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
@@ -254,10 +239,6 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'food-menu';
value: string | FoodMenu;
} | null)
| ({
relationTo: 'autosave-global';
value: string | AutosaveGlobal;
} | null);
globalSlug?: string | null;
user: {
@@ -371,18 +352,6 @@ export interface FoodMenuSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "autosave-global_select".
*/
export interface AutosaveGlobalSelect<T extends boolean = true> {
tenant?: T;
title?: T;
description?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".

View File

@@ -5,5 +5,3 @@ export const usersSlug = 'users'
export const menuItemsSlug = 'food-items'
export const menuSlug = 'food-menu'
export const autosaveGlobalSlug = 'autosave-global'