Compare commits

..

9 Commits

Author SHA1 Message Date
Elliot DeNolf
8b95218577 chore(release): v3.0.0-beta.43 [skip ci] 2024-06-07 17:45:28 -04:00
Jarrod Flesch
a79d23c631 chore: adjusts test config for draft validation (#6678) 2024-06-07 16:01:03 -04:00
Jarrod Flesch
52c81ad525 feat: adds draft validation option (#6677)
## Description

Allows draft validation to be enabled at the config level.

You can enable this by:
```ts
// ...collectionConfig
versions: {
  drafts: {
    validate: true // defaults to false
  }
}
```
2024-06-07 15:22:03 -04:00
Paul
8ec836737e chore: add turbo resolveAlias mock alias to hide webpack warnings (#6676) 2024-06-07 17:23:35 +00:00
Paul
e4a90294ea feat(plugin-redirects)!: update fields overrides to use a function (#6675)
## Description

Updates the `fields` override in plugin redirects to allow for
overriding

```ts
// before
overrides: {
  fields: [
    {
      type: 'text',
      name: 'customField',
    },
  ],
},

// current
overrides: {
  fields: ({ defaultFields }) => {
    return [
      ...defaultFields,
      {
        type: 'text',
        name: 'customField',
      },
    ]
  },
},
```


## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
2024-06-07 14:41:09 +00:00
Jacob Fletcher
7c8d562f03 fix(next): live preview device position when using zoom (#6665) 2024-06-07 10:17:49 -04:00
Alessio Gravili
11c3a65e63 feat(richtext-*): allow omitting the root editor property (#6660)
No need to add lexical/slate to the bundle if someone decides not to
make use of richText fields within payload at all
2024-06-06 17:57:03 +00:00
Paul
8dd5e4dc24 fix: max versions config not being respected on globals (#6654)
Closes https://github.com/payloadcms/payload/issues/6646

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2024-06-06 17:21:32 +00:00
Alessio Gravili
9bd9e7a986 feat!: upgrade minimum node 20 version from 20.6.0 to 20.9.0 (#6659)
**BREAKING**:
- This bumps the minimum required node version from node 20.6.0 to node
20.9.0. This is because 20.6.0 breaks type generation due to a CJS node
bug, and 20.9.0 is the next v20 LTS version. The minimum node 18 version
stays the same (18.20.2)
2024-06-06 17:15:21 +00:00
86 changed files with 1018 additions and 292 deletions

7
.vscode/launch.json vendored
View File

@@ -111,6 +111,13 @@
"request": "launch",
"type": "node-terminal"
},
{
"command": "node --no-deprecation test/dev.js field-error-states",
"cwd": "${workspaceFolder}",
"name": "Run Dev Field Error States",
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm run test:int live-preview",
"cwd": "${workspaceFolder}",

View File

@@ -22,6 +22,7 @@ Collections and Globals both support the same options for configuring drafts. Yo
| Draft Option | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `autosave` | Enable `autosave` to automatically save progress while documents are edited. To enable, set to `true` or pass an object with [options](/docs/versions/autosave). |
| `validate` | Set `validate` to `true` to validate draft documents when saved. Default is `false`. |
## Database changes

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"private": true,
"type": "module",
"scripts": {
@@ -158,7 +158,7 @@
"react-dom": "^19.0.0 || ^19.0.0-rc-f994737d14-20240522"
},
"engines": {
"node": ">=18.20.2",
"node": "^18.20.2 || >=20.9.0",
"pnpm": "^8.15.7"
},
"pnpm": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {
@@ -42,7 +42,7 @@
"payload": "workspace:*"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {
@@ -40,7 +40,7 @@
"payload": "workspace:*"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -41,6 +41,7 @@ import {
GraphQLUnionType,
} from 'graphql'
import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'
import { MissingEditorProp } from 'payload/errors'
import { tabHasName } from 'payload/types'
import { createDataloaderCacheKey, toWords } from 'payload/utilities'
@@ -476,6 +477,10 @@ function buildObjectType({
async resolve(parent, args, context: Context) {
let depth = config.defaultDepth
if (typeof args.depth !== 'undefined') depth = args.depth
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "The official live preview React SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -84,7 +84,7 @@
"payload": "workspace:*"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -151,8 +151,10 @@ export const Document: React.FC<AdminViewProps> = async ({
hasSavePermission &&
((collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave))
const validateDraftData =
collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.validate
if (shouldAutosave && !id && collectionSlug) {
if (shouldAutosave && !validateDraftData && !id && collectionSlug) {
const doc = await payload.create({
collection: collectionSlug,
data: {},

View File

@@ -10,16 +10,20 @@ export const DeviceContainer: React.FC<{
const { children } = props
const deviceFrameRef = React.useRef<HTMLDivElement>(null)
const outerFrameRef = React.useRef<HTMLDivElement>(null)
const { breakpoint, setMeasuredDeviceSize, size, zoom } = useLivePreviewContext()
const { breakpoint, setMeasuredDeviceSize, size: desiredSize, zoom } = useLivePreviewContext()
// Keep an accurate measurement of the actual device size as it is truly rendered
// This is helpful when `sizes` are non-number units like percentages, etc.
const { size: measuredDeviceSize } = useResize(deviceFrameRef)
const { size: measuredDeviceSize } = useResize(deviceFrameRef.current)
const { size: outerFrameSize } = useResize(outerFrameRef.current)
let deviceIsLargerThanFrame: boolean = false
// Sync the measured device size with the context so that other components can use it
// This happens from the bottom up so that as this component mounts and unmounts,
// Its size is freshly populated again upon re-mounting, i.e. going from iframe->popup->iframe
// its size is freshly populated again upon re-mounting, i.e. going from iframe->popup->iframe
useEffect(() => {
if (measuredDeviceSize) {
setMeasuredDeviceSize(measuredDeviceSize)
@@ -34,13 +38,34 @@ export const DeviceContainer: React.FC<{
if (
typeof zoom === 'number' &&
typeof size.width === 'number' &&
typeof size.height === 'number'
typeof desiredSize.width === 'number' &&
typeof desiredSize.height === 'number' &&
typeof measuredDeviceSize.width === 'number' &&
typeof measuredDeviceSize.height === 'number'
) {
const scaledWidth = size.width / zoom
const difference = scaledWidth - size.width
x = `${difference / 2}px`
margin = '0 auto'
const scaledDesiredWidth = desiredSize.width / zoom
const scaledDeviceWidth = measuredDeviceSize.width * zoom
const scaledDeviceDifferencePixels = scaledDesiredWidth - desiredSize.width
deviceIsLargerThanFrame = scaledDeviceWidth > outerFrameSize.width
if (deviceIsLargerThanFrame) {
if (zoom > 1) {
const differenceFromDeviceToFrame = measuredDeviceSize.width - outerFrameSize.width
if (differenceFromDeviceToFrame < 0) x = `${differenceFromDeviceToFrame / 2}px`
else x = '0'
} else {
x = '0'
}
} else {
if (zoom >= 1) {
x = `${scaledDeviceDifferencePixels / 2}px`
} else {
const differenceFromDeviceToFrame = outerFrameSize.width - scaledDeviceWidth
x = `${differenceFromDeviceToFrame / 2}px`
margin = '0'
}
}
}
}
@@ -48,21 +73,29 @@ export const DeviceContainer: React.FC<{
let height = zoom ? `${100 / zoom}%` : '100%'
if (breakpoint !== 'responsive') {
width = `${size?.width / (typeof zoom === 'number' ? zoom : 1)}px`
height = `${size?.height / (typeof zoom === 'number' ? zoom : 1)}px`
width = `${desiredSize?.width / (typeof zoom === 'number' ? zoom : 1)}px`
height = `${desiredSize?.height / (typeof zoom === 'number' ? zoom : 1)}px`
}
return (
<div
ref={deviceFrameRef}
ref={outerFrameRef}
style={{
height,
margin,
transform: `translate3d(${x}, 0, 0)`,
width,
height: '100%',
width: '100%',
}}
>
{children}
<div
ref={deviceFrameRef}
style={{
height,
margin,
transform: `translate3d(${x}, 0, 0)`,
width,
}}
>
{children}
</div>
</div>
)
}

View File

@@ -28,6 +28,13 @@ export const withPayload = (nextConfig = {}) => {
'libsql',
],
},
turbo: {
...(nextConfig?.experimental?.turbo || {}),
resolveAlias: {
...(nextConfig?.experimental?.turbo?.resolveAlias || {}),
'payload-mock-package': 'payload-mock-package',
},
},
},
headers: async () => {
const headersFromConfig = 'headers' in nextConfig ? await nextConfig.headers() : []

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",
@@ -137,7 +137,7 @@
}
},
"engines": {
"node": "^18.20.2 || >=20.6.0"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -84,6 +84,7 @@ export const sanitizeCollection = async (
if (sanitized.versions.drafts === true) {
sanitized.versions.drafts = {
autosave: false,
validate: false,
}
}
@@ -93,6 +94,10 @@ export const sanitizeCollection = async (
}
}
if (sanitized.versions.drafts.validate === undefined) {
sanitized.versions.drafts.validate = false
}
sanitized.fields = mergeBaseFields(sanitized.fields, baseVersionFields)
}
}

View File

@@ -221,6 +221,7 @@ const collectionSchema = joi.object().keys({
interval: joi.number(),
}),
),
validate: joi.boolean(),
}),
joi.boolean(),
),

View File

@@ -165,14 +165,6 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
Promise.resolve(),
)
// /////////////////////////////////////
// Write files to local storage
// /////////////////////////////////////
// if (!collectionConfig.upload.disableLocalStorage) {
// await uploadFiles(payload, filesToUpload, req.t)
// }
// /////////////////////////////////////
// beforeChange - Collection
// /////////////////////////////////////
@@ -203,7 +195,10 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
global: null,
operation: 'create',
req,
skipValidation: shouldSaveDraft,
skipValidation:
shouldSaveDraft &&
collectionConfig.versions.drafts &&
!collectionConfig.versions.drafts.validate,
})
// /////////////////////////////////////

View File

@@ -205,7 +205,10 @@ export const duplicateOperation = async <TSlug extends keyof GeneratedTypes['col
global: null,
operation,
req,
skipValidation: shouldSaveDraft,
skipValidation:
shouldSaveDraft &&
collectionConfig.versions.drafts &&
!collectionConfig.versions.drafts.validate,
})
// set req.locale back to the original locale

View File

@@ -270,7 +270,10 @@ export const updateOperation = async <TSlug extends keyof GeneratedTypes['collec
operation: 'update',
req,
skipValidation:
Boolean(collectionConfig.versions?.drafts) && data._status !== 'published',
shouldSaveDraft &&
collectionConfig.versions.drafts &&
!collectionConfig.versions.drafts.validate &&
data._status !== 'published',
})
// /////////////////////////////////////

View File

@@ -242,7 +242,11 @@ export const updateByIDOperation = async <TSlug extends keyof GeneratedTypes['co
global: null,
operation: 'update',
req,
skipValidation: Boolean(collectionConfig.versions?.drafts) && data._status !== 'published',
skipValidation:
shouldSaveDraft &&
collectionConfig.versions.drafts &&
!collectionConfig.versions.drafts.validate &&
data._status !== 'published',
})
// /////////////////////////////////////

View File

@@ -101,7 +101,7 @@ export default joi.object({
defaultMaxTextLength: joi.number(),
editor: joi
.object()
.required()
.optional()
.keys({
CellComponent: componentSchema.optional(),
FieldComponent: componentSchema.optional(),

View File

@@ -615,7 +615,7 @@ export type Config = {
*/
defaultMaxTextLength?: number
/** Default richtext editor to use for richText fields */
editor: RichTextAdapterProvider<any, any, any>
editor?: RichTextAdapterProvider<any, any, any>
/**
* Email Adapter
*
@@ -747,7 +747,7 @@ export type SanitizedConfig = Omit<
> & {
collections: SanitizedCollectionConfig[]
/** Default richtext editor to use for richText fields */
editor: RichTextAdapter<any, any, any>
editor?: RichTextAdapter<any, any, any>
endpoints: Endpoint[]
globals: SanitizedGlobalConfig[]
i18n: Required<I18nOptions>

View File

@@ -12,6 +12,7 @@ export { InvalidFieldName } from './InvalidFieldName.js'
export { InvalidFieldRelationship } from './InvalidFieldRelationship.js'
export { LockedAuth } from './LockedAuth.js'
export { MissingCollectionLabel } from './MissingCollectionLabel.js'
export { MissingEditorProp } from './MissingEditorProp.js'
export { MissingFieldInputOptions } from './MissingFieldInputOptions.js'
export { MissingFieldType } from './MissingFieldType.js'
export { MissingFile } from './MissingFile.js'

View File

@@ -2,8 +2,10 @@ export {
APIError,
AuthenticationError,
DuplicateCollection,
DuplicateFieldName,
DuplicateGlobal,
ErrorDeletingFile,
FileRetrievalError,
FileUploadError,
Forbidden,
InvalidConfiguration,
@@ -11,6 +13,7 @@ export {
InvalidFieldRelationship,
LockedAuth,
MissingCollectionLabel,
MissingEditorProp,
MissingFieldInputOptions,
MissingFieldType,
MissingFile,

View File

@@ -158,7 +158,7 @@ export const sanitizeFields = async ({
// config.editor should be sanitized at this point
field.editor = _config.editor
} else {
throw new MissingEditorProp(field)
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
}

View File

@@ -5,6 +5,7 @@ import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import getValueWithDefault from '../../getDefaultValue.js'
import { relationshipPopulationPromise } from './relationshipPopulationPromise.js'
@@ -143,6 +144,9 @@ export const promise = async ({
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}

View File

@@ -268,6 +268,9 @@ export const richText: Validate<object, unknown, unknown, RichTextField> = async
value,
options,
) => {
if (!options?.editor) {
throw new Error('richText field has no editor property.')
}
if (typeof options?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}

View File

@@ -8,6 +8,7 @@ import type { SanitizedConfig } from '../config/types.js'
import type { Field, FieldAffectingData, Option } from '../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import { MissingEditorProp } from '../errors/MissingEditorProp.js'
import { fieldAffectsData, tabHasName } from '../fields/config/types.js'
import { deepCopyObject } from './deepCopyObject.js'
import { toWords } from './formatLabels.js'
@@ -195,6 +196,9 @@ export function fieldsToJSONSchema(
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}

View File

@@ -68,7 +68,7 @@ export const enforceMaxVersions = async ({
}
await payload.db.deleteVersions({
collection: collection?.slug,
collection: slug,
req,
where: deleteQuery,
})

View File

@@ -4,10 +4,12 @@ export type Autosave = {
export type IncomingDrafts = {
autosave?: Autosave | boolean
validate?: boolean
}
export type SanitizedDrafts = {
autosave: Autosave | false
validate: boolean
}
export type IncomingCollectionVersions = {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,34 +0,0 @@
// @ts-nocheck
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item: unknown): boolean {
return item && typeof item === 'object' && !Array.isArray(item)
}
/**
* Deep merge two objects.
* @param target
* @param ...sources
*/
export default function deepMerge<T, R>(target: T, source: R): T {
const output = { ...target }
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
if (isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] })
} else {
output[key] = deepMerge(target[key], source[key])
}
} else {
Object.assign(output, { [key]: source[key] })
}
})
}
return output
}

View File

@@ -1,80 +1,85 @@
import type { Config } from 'payload/config'
import type { CollectionConfig, Field } from 'payload/types'
import type { RedirectsPluginConfig } from './types.js'
import deepMerge from './deepMerge.js'
export const redirectsPlugin =
(pluginConfig: RedirectsPluginConfig) =>
(incomingConfig: Config): Config => ({
...incomingConfig,
collections: [
...(incomingConfig?.collections || []),
deepMerge(
{
slug: 'redirects',
access: {
read: (): boolean => true,
},
admin: {
defaultColumns: ['from', 'to.type', 'createdAt'],
},
fields: [
{
name: 'from',
type: 'text',
index: true,
label: 'From URL',
required: true,
(incomingConfig: Config): Config => {
const defaultFields: Field[] = [
{
name: 'from',
type: 'text',
index: true,
label: 'From URL',
required: true,
},
{
name: 'to',
type: 'group',
fields: [
{
name: 'type',
type: 'radio',
admin: {
layout: 'horizontal',
},
{
name: 'to',
type: 'group',
fields: [
{
name: 'type',
type: 'radio',
admin: {
layout: 'horizontal',
},
defaultValue: 'reference',
label: 'To URL Type',
options: [
{
label: 'Internal link',
value: 'reference',
},
{
label: 'Custom URL',
value: 'custom',
},
],
},
{
name: 'reference',
type: 'relationship',
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
label: 'Document to redirect to',
relationTo: pluginConfig?.collections || [],
required: true,
},
{
name: 'url',
type: 'text',
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
label: 'Custom URL',
required: true,
},
],
label: false,
defaultValue: 'reference',
label: 'To URL Type',
options: [
{
label: 'Internal link',
value: 'reference',
},
{
label: 'Custom URL',
value: 'custom',
},
],
},
{
name: 'reference',
type: 'relationship',
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
],
},
pluginConfig?.overrides || {},
),
],
})
label: 'Document to redirect to',
relationTo: pluginConfig?.collections || [],
required: true,
},
{
name: 'url',
type: 'text',
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
label: 'Custom URL',
required: true,
},
],
label: false,
},
]
const redirectsCollection: CollectionConfig = {
...(pluginConfig?.overrides || {}),
slug: pluginConfig?.overrides?.slug || 'redirects',
access: {
read: () => true,
...(pluginConfig?.overrides?.access || {}),
},
admin: {
defaultColumns: ['from', 'to.type', 'createdAt'],
...(pluginConfig?.overrides?.admin || {}),
},
fields:
pluginConfig?.overrides?.fields && typeof pluginConfig?.overrides?.fields === 'function'
? pluginConfig?.overrides.fields({ defaultFields })
: defaultFields,
}
return {
...incomingConfig,
collections: [...(incomingConfig?.collections || []), redirectsCollection],
}
}

View File

@@ -1,6 +1,8 @@
import type { CollectionConfig } from 'payload/types'
import type { CollectionConfig, Field } from 'payload/types'
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
export type RedirectsPluginConfig = {
collections?: string[]
overrides?: Partial<CollectionConfig>
overrides?: Partial<Omit<CollectionConfig, 'fields'>> & { fields: FieldsOverride }
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-relationship-object-ids",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "A Payload plugin to store all relationship IDs as ObjectIDs",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Search plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "SEO plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -95,7 +95,7 @@
"react-dom": "^19.0.0 || ^19.0.0-rc-f994737d14-20240522"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-slate",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "The officially supported Slate richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -55,7 +55,7 @@
"react": "^19.0.0 || ^19.0.0-rc-f994737d14-20240522"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-azure",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Payload storage adapter for Azure Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {
@@ -45,7 +45,7 @@
"payload": "workspace:*"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-gcs",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Payload storage adapter for Google Cloud Storage",
"homepage": "https://payloadcms.com",
"repository": {
@@ -42,7 +42,7 @@
"payload": "workspace:*"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-s3",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Payload storage adapter for Amazon S3",
"homepage": "https://payloadcms.com",
"repository": {
@@ -43,7 +43,7 @@
"payload": "workspace:*"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-uploadthing",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Payload storage adapter for uploadthing",
"homepage": "https://payloadcms.com",
"repository": {
@@ -42,7 +42,7 @@
"payload": "workspace:*"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-vercel-blob",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"description": "Payload storage adapter for Vercel Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {
@@ -42,7 +42,7 @@
"payload": "workspace:*"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/translations",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/ui",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.43",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -153,7 +153,7 @@
"react-dom": "^19.0.0 || ^19.0.0-rc-f994737d14-20240522"
},
"engines": {
"node": ">=18.20.2"
"node": "^18.20.2 || >=20.9.0"
},
"publishConfig": {
"exports": {

View File

@@ -3,9 +3,9 @@
import type { ClientCollectionConfig, ClientGlobalConfig } from 'payload/types'
import React, { useEffect, useRef, useState } from 'react'
import { toast } from 'react-toastify'
import { useAllFormFields, useFormModified } from '../../forms/Form/context.js'
import { useDebounce } from '../../hooks/useDebounce.js'
import { useAllFormFields, useForm, useFormModified } from '../../forms/Form/context.js'
import { useConfig } from '../../providers/Config/index.js'
import { useDocumentEvents } from '../../providers/DocumentEvents/index.js'
import { useDocumentInfo } from '../../providers/DocumentInfo/index.js'
@@ -36,6 +36,7 @@ export const Autosave: React.FC<Props> = ({
} = useConfig()
const { docConfig, getVersions, versions } = useDocumentInfo()
const { reportUpdate } = useDocumentEvents()
const { dispatchFields, setSubmitted } = useForm()
const versionsConfig = docConfig?.versions
const [fields] = useAllFormFields()
@@ -49,7 +50,6 @@ export const Autosave: React.FC<Props> = ({
const [saving, setSaving] = useState(false)
const [lastSaved, setLastSaved] = useState<number>()
const debouncedFields = useDebounce(fields, interval)
const fieldRef = useRef(fields)
const modifiedRef = useRef(modified)
const localeRef = useRef(locale)
@@ -117,26 +117,77 @@ export const Autosave: React.FC<Props> = ({
})
void getVersions()
}
if (
versionsConfig?.drafts &&
versionsConfig?.drafts?.validate &&
res.status === 400
) {
const json = await res.json()
if (Array.isArray(json.errors)) {
const [fieldErrors, nonFieldErrors] = json.errors.reduce(
([fieldErrs, nonFieldErrs], err) => {
const newFieldErrs = []
const newNonFieldErrs = []
if (err?.message) {
newNonFieldErrs.push(err)
}
if (Array.isArray(err?.data)) {
err.data.forEach((dataError) => {
if (dataError?.field) {
newFieldErrs.push(dataError)
} else {
newNonFieldErrs.push(dataError)
}
})
}
return [
[...fieldErrs, ...newFieldErrs],
[...nonFieldErrs, ...newNonFieldErrs],
]
},
[[], []],
)
dispatchFields({
type: 'ADD_SERVER_ERRORS',
errors: fieldErrors,
})
nonFieldErrors.forEach((err) => {
toast.error(err.message || i18n.t('error:unknown'))
})
return
}
setSubmitted(true)
}
}
setSaving(false)
}, 1000)
}, interval)
}
}
}
void autosave()
}, [
i18n,
debouncedFields,
modified,
serverURL,
api,
collection,
globalDoc,
reportUpdate,
id,
dispatchFields,
getVersions,
globalDoc,
i18n,
id,
interval,
modified,
reportUpdate,
serverURL,
setSubmitted,
versionsConfig?.drafts,
])
useEffect(() => {

View File

@@ -74,6 +74,9 @@ export const DocumentControls: React.FC<{
collectionConfig && id && !disableActions && (hasCreatePermission || hasDeletePermission),
)
const unsavedDraftWithValidations =
!id && collectionConfig?.versions?.drafts && collectionConfig.versions?.drafts.validate
return (
<Gutter className={baseClass}>
<div className={`${baseClass}__wrapper`}>
@@ -103,7 +106,8 @@ export const DocumentControls: React.FC<{
</li>
)}
{((collectionConfig?.versions?.drafts &&
collectionConfig?.versions?.drafts?.autosave) ||
collectionConfig?.versions?.drafts?.autosave &&
!unsavedDraftWithValidations) ||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave)) &&
hasSavePermission && (
<li className={`${baseClass}__list-item`}>
@@ -168,6 +172,7 @@ export const DocumentControls: React.FC<{
<React.Fragment>
{((collectionConfig?.versions?.drafts &&
!collectionConfig?.versions?.drafts?.autosave) ||
unsavedDraftWithValidations ||
(globalConfig?.versions?.drafts &&
!globalConfig?.versions?.drafts?.autosave)) && (
<SaveDraftButton CustomComponent={componentMap.SaveDraftButton} />

View File

@@ -2,7 +2,11 @@
/* eslint-disable no-shadow */
import { useEffect, useRef, useState } from 'react'
type Intersect = [setNode: React.Dispatch<Element>, entry: IntersectionObserverEntry]
type Intersect = [
setNode: React.Dispatch<HTMLElement>,
entry: IntersectionObserverEntry,
node: HTMLElement,
]
export const useIntersect = (
{ root = null, rootMargin = '0px', threshold = 0 } = {},
@@ -33,5 +37,5 @@ export const useIntersect = (
return () => currentObserver.disconnect()
}, [node, disable])
return [setNode, entry]
return [setNode, entry, node]
}

View File

@@ -12,15 +12,13 @@ interface Resize {
size?: Size
}
export const useResize = (ref: React.MutableRefObject<HTMLElement>): Resize => {
export const useResize = (element: HTMLElement): Resize => {
const [size, setSize] = useState<Size>()
useEffect(() => {
let observer: any // eslint-disable-line
const { current: currentRef } = ref
if (currentRef) {
if (element) {
observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
const {
@@ -53,15 +51,15 @@ export const useResize = (ref: React.MutableRefObject<HTMLElement>): Resize => {
})
})
observer.observe(currentRef)
observer.observe(element)
}
return () => {
if (observer) {
observer.unobserve(currentRef)
observer.unobserve(element)
}
}
}, [ref])
}, [element])
return {
size,

View File

@@ -10,6 +10,7 @@ import type {
SanitizedConfig,
} from 'payload/types'
import { MissingEditorProp } from 'payload/errors'
import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/types'
import React, { Fragment } from 'react'
@@ -566,6 +567,9 @@ export const mapFields = (args: {
style: field.admin?.style,
width: field.admin?.width,
}
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}

View File

@@ -1,6 +1,7 @@
import type { I18n } from '@payloadcms/translations'
import type { Field, SanitizedConfig } from 'payload/types'
import { MissingEditorProp } from 'payload/errors'
import { tabHasName } from 'payload/types'
import type { FieldSchemaMap } from './types.js'
@@ -68,6 +69,9 @@ export const traverseFields = ({
break
case 'richText':
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}

View File

@@ -36,7 +36,7 @@
"typescript": "^5.4.5"
},
"engines": {
"node": "^18.20.2 || >=20.6.0"
"node": "^18.20.2 || >=20.9.0"
},
"pnpm": {
"overrides": {

View File

@@ -36,7 +36,7 @@
"typescript": "^5.4.5"
},
"engines": {
"node": "^18.20.2 || >=20.6.0"
"node": "^18.20.2 || >=20.9.0"
},
"pnpm": {
"overrides": {

View File

@@ -3,13 +3,13 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"ci": "payload migrate && pnpm build",
"dev": "next dev",
"generate:types": "payload generate:types",
"lint": "next lint",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"generate:types": "payload generate:types"
"start": "next start"
},
"dependencies": {
"@payloadcms/db-postgres": "beta",
@@ -34,5 +34,8 @@
"postcss": "^8",
"tailwindcss": "^3.3.0",
"typescript": "^5"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
}
}

View File

@@ -77,6 +77,9 @@
"ts-node": "10.9.1",
"typescript": "^5.4.2"
},
"engines": {
"node": "^18.20.2 || >=20.9.0"
},
"overrides": {
"@types/react": "18.2.74"
}

View File

@@ -36,7 +36,7 @@
"typescript": "^5.4.5"
},
"engines": {
"node": "^18.20.2 || >=20.6.0"
"node": "^18.20.2 || >=20.9.0"
},
"pnpm": {
"overrides": {

View File

@@ -18,13 +18,13 @@
"@payloadcms/next": "beta",
"@payloadcms/plugin-cloud": "beta",
"@payloadcms/richtext-lexical": "beta",
"@payloadcms/storage-vercel-blob": "beta",
"cross-env": "^7.0.3",
"graphql": "^16.8.1",
"next": "15.0.0-rc.0",
"payload": "beta",
"react": "^19.0.0-rc-f994737d14-20240522",
"react-dom": "^19.0.0-rc-f994737d14-20240522",
"@payloadcms/storage-vercel-blob": "beta"
"react-dom": "^19.0.0-rc-f994737d14-20240522"
},
"devDependencies": {
"@types/node": "^20.12.12",
@@ -36,7 +36,7 @@
"typescript": "^5.4.5"
},
"engines": {
"node": "^18.20.2 || >=20.6.0"
"node": "^18.20.2 || >=20.9.0"
},
"pnpm": {
"overrides": {

View File

@@ -14,17 +14,17 @@
"start": "cross-env NODE_OPTIONS=--no-deprecation next start"
},
"dependencies": {
"@payloadcms/db-postgres": "beta",
"@payloadcms/next": "beta",
"@payloadcms/plugin-cloud": "beta",
"@payloadcms/richtext-lexical": "beta",
"@payloadcms/storage-vercel-blob": "beta",
"cross-env": "^7.0.3",
"graphql": "^16.8.1",
"next": "15.0.0-rc.0",
"payload": "beta",
"react": "^19.0.0-rc-f994737d14-20240522",
"react-dom": "^19.0.0-rc-f994737d14-20240522",
"@payloadcms/db-postgres": "beta",
"@payloadcms/storage-vercel-blob": "beta"
"react-dom": "^19.0.0-rc-f994737d14-20240522"
},
"devDependencies": {
"@types/node": "^20.12.12",
@@ -36,7 +36,7 @@
"typescript": "^5.4.5"
},
"engines": {
"node": "^18.20.2 || >=20.6.0"
"node": "^18.20.2 || >=20.9.0"
},
"pnpm": {
"overrides": {

View File

@@ -0,0 +1,12 @@
import type { CollectionConfig } from 'payload/types'
import { slugs } from '../../shared.js'
import { ValidateDraftsOn } from '../ValidateDraftsOn/index.js'
export const ValidateDraftsOff: CollectionConfig = {
...ValidateDraftsOn,
slug: slugs.validateDraftsOff,
versions: {
drafts: true,
},
}

View File

@@ -0,0 +1,19 @@
import type { CollectionConfig } from 'payload/types'
import { slugs } from '../../shared.js'
export const ValidateDraftsOn: CollectionConfig = {
slug: slugs.validateDraftsOn,
fields: [
{
name: 'title',
type: 'text',
required: true,
},
],
versions: {
drafts: {
validate: true,
},
},
}

View File

@@ -0,0 +1,15 @@
import type { CollectionConfig } from 'payload/types'
import { slugs } from '../../shared.js'
import { ValidateDraftsOn } from '../ValidateDraftsOn/index.js'
export const ValidateDraftsOnAndAutosave: CollectionConfig = {
...ValidateDraftsOn,
slug: slugs.validateDraftsOnAutosave,
versions: {
drafts: {
autosave: true,
validate: true,
},
},
}

View File

@@ -2,9 +2,18 @@ import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
import { ErrorFieldsCollection } from './collections/ErrorFields/index.js'
import Uploads from './collections/Upload/index.js'
import { ValidateDraftsOff } from './collections/ValidateDraftsOff/index.js'
import { ValidateDraftsOn } from './collections/ValidateDraftsOn/index.js'
import { ValidateDraftsOnAndAutosave } from './collections/ValidateDraftsOnAutosave/index.js'
export default buildConfigWithDefaults({
collections: [ErrorFieldsCollection, Uploads],
collections: [
ErrorFieldsCollection,
Uploads,
ValidateDraftsOn,
ValidateDraftsOff,
ValidateDraftsOnAndAutosave,
],
onInit: async (payload) => {
await payload.create({
collection: 'users',

View File

@@ -1,12 +1,18 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { AdminUrlUtil } from 'helpers/adminUrlUtil.js'
import path from 'path'
import { fileURLToPath } from 'url'
import { ensureAutoLoginAndCompilationIsDone, initPageConsoleErrorCatch } from '../helpers.js'
import {
ensureAutoLoginAndCompilationIsDone,
initPageConsoleErrorCatch,
saveDocAndAssert,
} from '../helpers.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { TEST_TIMEOUT_LONG } from '../playwright.config.js'
import { slugs } from './shared.js'
const { beforeAll, describe } = test
const filename = fileURLToPath(import.meta.url)
@@ -15,10 +21,16 @@ const dirname = path.dirname(filename)
describe('field error states', () => {
let serverURL: string
let page: Page
let validateDraftsOff: AdminUrlUtil
let validateDraftsOn: AdminUrlUtil
let validateDraftsOnAutosave: AdminUrlUtil
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ serverURL } = await initPayloadE2ENoConfig({ dirname }))
validateDraftsOff = new AdminUrlUtil(serverURL, slugs.validateDraftsOff)
validateDraftsOn = new AdminUrlUtil(serverURL, slugs.validateDraftsOn)
validateDraftsOnAutosave = new AdminUrlUtil(serverURL, slugs.validateDraftsOnAutosave)
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
@@ -57,4 +69,27 @@ describe('field error states', () => {
)
expect(errorPill).toBeNull()
})
describe('draft validations', () => {
// eslint-disable-next-line playwright/expect-expect
test('should not validate drafts by default', async () => {
await page.goto(validateDraftsOff.create)
await saveDocAndAssert(page, '#action-save-draft')
})
// eslint-disable-next-line playwright/expect-expect
test('should validate drafts when enabled', async () => {
await page.goto(validateDraftsOn.create)
await saveDocAndAssert(page, '#action-save-draft', 'error')
})
// eslint-disable-next-line playwright/expect-expect
test('should show validation errors when validate and autosave are enabled', async () => {
await page.goto(validateDraftsOnAutosave.create)
await page.locator('#field-title').fill('valid')
await saveDocAndAssert(page)
await page.locator('#field-title').fill('')
await saveDocAndAssert(page, '#action-save', 'error')
})
})
})

View File

@@ -10,6 +10,9 @@ export interface Config {
collections: {
'error-fields': ErrorField;
uploads: Upload;
'validate-drafts-on': ValidateDraftsOn;
'validate-drafts-off': ValidateDraftsOff;
'validate-drafts-on-autosave': ValidateDraftsOnAutosave;
users: User;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -255,6 +258,41 @@ export interface Upload {
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "validate-drafts-on".
*/
export interface ValidateDraftsOn {
id: string;
title: string;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "validate-drafts-off".
*/
export interface ValidateDraftsOff {
id: string;
title: string;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "validate-drafts-on-autosave".
*/
export interface ValidateDraftsOnAutosave {
id: string;
title: string;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema

View File

@@ -0,0 +1,5 @@
export const slugs = {
validateDraftsOn: 'validate-drafts-on',
validateDraftsOnAutosave: 'validate-drafts-on-autosave',
validateDraftsOff: 'validate-drafts-off',
}

View File

@@ -180,11 +180,20 @@ export async function saveDocHotkeyAndAssert(page: Page): Promise<void> {
await expect(page.locator('.Toastify')).toContainText('successfully')
}
export async function saveDocAndAssert(page: Page, selector = '#action-save'): Promise<void> {
export async function saveDocAndAssert(
page: Page,
selector = '#action-save',
expectation: 'error' | 'success' = 'success',
): Promise<void> {
await wait(500) // TODO: Fix this
await page.click(selector, { delay: 100 })
await expect(page.locator('.Toastify')).toContainText('successfully')
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
if (expectation === 'success') {
await expect(page.locator('.Toastify')).toContainText('successfully')
await expect.poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }).not.toContain('create')
} else {
await expect(page.locator('.Toastify .Toastify__toast--error')).toBeVisible()
}
}
export async function openNav(page: Page): Promise<void> {

View File

@@ -11,6 +11,7 @@ import { Footer } from './globals/Footer.js'
import { Header } from './globals/Header.js'
import { seed } from './seed/index.js'
import {
desktopBreakpoint,
mobileBreakpoint,
pagesSlug,
postsSlug,
@@ -25,7 +26,7 @@ export default buildConfigWithDefaults({
// You can define any of these properties on a per collection or global basis
// The Live Preview config cascades from the top down, properties are inherited from here
url: formatLivePreviewURL,
breakpoints: [mobileBreakpoint],
breakpoints: [mobileBreakpoint, desktopBreakpoint],
collections: [pagesSlug, postsSlug, ssrPagesSlug, ssrAutosavePagesSlug],
globals: ['header', 'footer'],
},

View File

@@ -8,19 +8,29 @@ import {
ensureAutoLoginAndCompilationIsDone,
exactText,
initPageConsoleErrorCatch,
navigateToListCellLink,
saveDocAndAssert,
} from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { initPayloadE2ENoConfig } from '../helpers/initPayloadE2ENoConfig.js'
import { POLL_TOPASS_TIMEOUT, TEST_TIMEOUT_LONG } from '../playwright.config.js'
import {
ensureDeviceIsCentered,
ensureDeviceIsLeftAligned,
goToCollectionLivePreview,
goToDoc,
goToGlobalLivePreview,
selectLivePreviewBreakpoint,
selectLivePreviewZoom,
} from './helpers.js'
import {
desktopBreakpoint,
mobileBreakpoint,
pagesSlug,
renderedPageTitleID,
ssrAutosavePagesSlug,
ssrPagesSlug,
} from './shared.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -34,25 +44,6 @@ describe('Live Preview', () => {
let ssrPagesURLUtil: AdminUrlUtil
let ssrAutosavePostsURLUtil: AdminUrlUtil
const goToDoc = async (page: Page, urlUtil: AdminUrlUtil) => {
await page.goto(urlUtil.list)
await page.waitForURL(urlUtil.list)
await navigateToListCellLink(page)
}
const goToCollectionPreview = async (page: Page, urlUtil: AdminUrlUtil): Promise<void> => {
await goToDoc(page, urlUtil)
await page.goto(`${page.url()}/preview`)
await page.waitForURL(`**/preview`)
}
const goToGlobalPreview = async (page: Page, slug: string): Promise<void> => {
const global = new AdminUrlUtil(serverURL, slug)
const previewURL = `${global.global(slug)}/preview`
await page.goto(previewURL)
await page.waitForURL(previewURL)
}
beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(TEST_TIMEOUT_LONG)
;({ serverURL } = await initPayloadE2ENoConfig({ dirname }))
@@ -88,18 +79,18 @@ describe('Live Preview', () => {
})
test('collection — has route', async () => {
await goToCollectionPreview(page, pagesURLUtil)
await goToCollectionLivePreview(page, pagesURLUtil)
await expect(page.locator('.live-preview')).toBeVisible()
})
test('collection — renders iframe', async () => {
await goToCollectionPreview(page, pagesURLUtil)
await goToCollectionLivePreview(page, pagesURLUtil)
const iframe = page.locator('iframe.live-preview-iframe')
await expect(iframe).toBeVisible()
})
test('collection — re-renders iframe client-side when form state changes', async () => {
await goToCollectionPreview(page, pagesURLUtil)
await goToCollectionLivePreview(page, pagesURLUtil)
const titleField = page.locator('#field-title')
const frame = page.frameLocator('iframe.live-preview-iframe').first()
@@ -129,7 +120,7 @@ describe('Live Preview', () => {
})
test('collection ssr — re-render iframe when save is made', async () => {
await goToCollectionPreview(page, ssrPagesURLUtil)
await goToCollectionLivePreview(page, ssrPagesURLUtil)
const titleField = page.locator('#field-title')
const frame = page.frameLocator('iframe.live-preview-iframe').first()
@@ -159,7 +150,7 @@ describe('Live Preview', () => {
})
test('collection ssr — re-render iframe when autosave is made', async () => {
await goToCollectionPreview(page, ssrAutosavePostsURLUtil)
await goToCollectionLivePreview(page, ssrAutosavePostsURLUtil)
const titleField = page.locator('#field-title')
const frame = page.frameLocator('iframe.live-preview-iframe').first()
@@ -189,12 +180,12 @@ describe('Live Preview', () => {
})
test('collection — should show live-preview view-level action in live-preview view', async () => {
await goToCollectionPreview(page, pagesURLUtil)
await goToCollectionLivePreview(page, pagesURLUtil)
await expect(page.locator('.app-header .collection-live-preview-button')).toHaveCount(1)
})
test('global — should show live-preview view-level action in live-preview view', async () => {
await goToGlobalPreview(page, 'footer')
await goToGlobalLivePreview(page, 'footer', serverURL)
await expect(page.locator('.app-header .global-live-preview-button')).toHaveCount(1)
})
@@ -220,7 +211,7 @@ describe('Live Preview', () => {
test('global — has route', async () => {
const url = page.url()
await goToGlobalPreview(page, 'header')
await goToGlobalLivePreview(page, 'header', serverURL)
await expect(() => expect(page.url()).toBe(`${url}/preview`)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
@@ -228,13 +219,13 @@ describe('Live Preview', () => {
})
test('global — renders iframe', async () => {
await goToGlobalPreview(page, 'header')
await goToGlobalLivePreview(page, 'header', serverURL)
const iframe = page.locator('iframe.live-preview-iframe')
await expect(iframe).toBeVisible()
})
test('global — can edit fields', async () => {
await goToGlobalPreview(page, 'header')
await goToGlobalLivePreview(page, 'header', serverURL)
const field = page.locator('input#field-navItems__0__link__newTab') //field-navItems__0__link__newTab
await expect(field).toBeVisible()
await expect(field).toBeEnabled()
@@ -242,14 +233,14 @@ describe('Live Preview', () => {
await saveDocAndAssert(page)
})
test('properly measures iframe and displays size', async () => {
test('device — properly measures size', async () => {
await page.goto(pagesURLUtil.create)
await page.waitForURL(pagesURLUtil.create)
await page.locator('#field-title').fill('Title 3')
await page.locator('#field-slug').fill('slug-3')
await saveDocAndAssert(page)
await goToCollectionPreview(page, pagesURLUtil)
await goToCollectionLivePreview(page, pagesURLUtil)
const iframe = page.locator('iframe')
@@ -291,37 +282,16 @@ describe('Live Preview', () => {
})
})
test('resizes iframe to specified breakpoint', async () => {
test('device — resizes to specified breakpoint', async () => {
await page.goto(pagesURLUtil.create)
await page.waitForURL(pagesURLUtil.create)
await page.locator('#field-title').fill('Title 4')
await page.locator('#field-slug').fill('slug-4')
await saveDocAndAssert(page)
await goToCollectionPreview(page, pagesURLUtil)
await goToCollectionLivePreview(page, pagesURLUtil)
// Check that the breakpoint select is present
const breakpointSelector = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button',
)
await expect(() => expect(breakpointSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
// Select the mobile breakpoint
await breakpointSelector.first().click()
await page
.locator(`.live-preview-toolbar-controls__breakpoint button.popup-button-list__button`)
.filter({ hasText: mobileBreakpoint.label })
.click()
// Make sure the value has been set
await expect(breakpointSelector).toContainText(mobileBreakpoint.label)
const option = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button-list__button--selected',
)
await expect(option).toHaveText(mobileBreakpoint.label)
await selectLivePreviewBreakpoint(page, mobileBreakpoint.label)
// Measure the size of the iframe against the specified breakpoint
const iframe = page.locator('iframe')
@@ -382,4 +352,34 @@ describe('Live Preview', () => {
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('device — centers device when smaller than frame despite zoom', async () => {
await goToCollectionLivePreview(page, pagesURLUtil)
await selectLivePreviewBreakpoint(page, mobileBreakpoint.label)
await ensureDeviceIsCentered(page)
await selectLivePreviewZoom(page, '75%')
await ensureDeviceIsCentered(page)
await selectLivePreviewZoom(page, '50%')
await ensureDeviceIsCentered(page)
await selectLivePreviewZoom(page, '125%')
await ensureDeviceIsCentered(page)
await selectLivePreviewZoom(page, '200%')
await ensureDeviceIsCentered(page)
expect(true).toBeTruthy()
})
test('device — left-aligns device when larger than frame despite zoom', async () => {
await goToCollectionLivePreview(page, pagesURLUtil)
await selectLivePreviewBreakpoint(page, desktopBreakpoint.label)
await ensureDeviceIsLeftAligned(page)
await selectLivePreviewZoom(page, '75%')
await ensureDeviceIsLeftAligned(page)
await selectLivePreviewZoom(page, '50%')
await ensureDeviceIsLeftAligned(page)
await selectLivePreviewZoom(page, '125%')
await ensureDeviceIsLeftAligned(page)
await selectLivePreviewZoom(page, '200%')
await ensureDeviceIsLeftAligned(page)
expect(true).toBeTruthy()
})
})

View File

@@ -0,0 +1,122 @@
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { exactText, navigateToListCellLink } from '../helpers.js'
import { AdminUrlUtil } from '../helpers/adminUrlUtil.js'
import { POLL_TOPASS_TIMEOUT } from '../playwright.config.js'
export const goToDoc = async (page: Page, urlUtil: AdminUrlUtil) => {
await page.goto(urlUtil.list)
await page.waitForURL(urlUtil.list)
await navigateToListCellLink(page)
}
export const goToCollectionLivePreview = async (
page: Page,
urlUtil: AdminUrlUtil,
): Promise<void> => {
await goToDoc(page, urlUtil)
await page.goto(`${page.url()}/preview`)
await page.waitForURL(`**/preview`)
}
export const goToGlobalLivePreview = async (
page: Page,
slug: string,
serverURL: string,
): Promise<void> => {
const global = new AdminUrlUtil(serverURL, slug)
const previewURL = `${global.global(slug)}/preview`
await page.goto(previewURL)
await page.waitForURL(previewURL)
}
export const selectLivePreviewBreakpoint = async (page: Page, breakpointLabel: string) => {
const breakpointSelector = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button',
)
await expect(() => expect(breakpointSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await breakpointSelector.first().click()
await page
.locator(`.live-preview-toolbar-controls__breakpoint button.popup-button-list__button`)
.filter({ hasText: breakpointLabel })
.click()
await expect(breakpointSelector).toContainText(breakpointLabel)
const option = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button-list__button--selected',
)
await expect(option).toHaveText(breakpointLabel)
}
export const selectLivePreviewZoom = async (page: Page, zoomLabel: string) => {
const zoomSelector = page.locator('.live-preview-toolbar-controls__zoom button.popup-button')
await expect(() => expect(zoomSelector).toBeTruthy()).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
await zoomSelector.first().click()
const zoomOption = page.locator(
'.live-preview-toolbar-controls__zoom button.popup-button-list__button',
{
hasText: exactText(zoomLabel),
},
)
expect(zoomOption).toBeTruthy()
await zoomOption.click()
await expect(zoomSelector).toContainText(zoomLabel)
const option = page.locator(
'.live-preview-toolbar-controls__zoom button.popup-button-list__button--selected',
)
await expect(option).toHaveText(zoomLabel)
}
export const ensureDeviceIsCentered = async (page: Page) => {
const main = page.locator('.live-preview-window__main')
const iframe = page.locator('iframe.live-preview-iframe')
const mainBoxAfterZoom = await main.boundingBox()
const iframeBoxAfterZoom = await iframe.boundingBox()
const distanceFromIframeLeftToMainLeftAfterZoom = Math.abs(
mainBoxAfterZoom?.x - iframeBoxAfterZoom?.x,
)
const distanceFromIFrameRightToMainRightAfterZoom = Math.abs(
mainBoxAfterZoom?.x +
mainBoxAfterZoom?.width -
iframeBoxAfterZoom?.x -
iframeBoxAfterZoom?.width,
)
await expect(() =>
expect(distanceFromIframeLeftToMainLeftAfterZoom).toBe(
distanceFromIFrameRightToMainRightAfterZoom,
),
).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}
export const ensureDeviceIsLeftAligned = async (page: Page) => {
const main = page.locator('.live-preview-window__main > div')
const iframe = page.locator('iframe.live-preview-iframe')
const mainBoxAfterZoom = await main.boundingBox()
const iframeBoxAfterZoom = await iframe.boundingBox()
const distanceFromIframeLeftToMainLeftAfterZoom = Math.abs(
mainBoxAfterZoom?.x - iframeBoxAfterZoom?.x,
)
await expect(() => expect(distanceFromIframeLeftToMainLeftAfterZoom).toBe(0)).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
}

View File

@@ -15,4 +15,11 @@ export const mobileBreakpoint = {
height: 667,
}
export const desktopBreakpoint = {
label: 'Desktop',
name: 'desktop',
width: 1920,
height: 1080,
}
export const renderedPageTitleID = 'rendered-page-title'

View File

@@ -27,6 +27,17 @@ export default buildConfigWithDefaults({
plugins: [
redirectsPlugin({
collections: ['pages'],
overrides: {
fields: ({ defaultFields }) => {
return [
...defaultFields,
{
type: 'text',
name: 'customField',
},
]
},
},
}),
],
})

View File

@@ -0,0 +1,125 @@
import type { CollectionConfig } from 'payload/types'
import CollectionVersionButton from '../elements/CollectionVersionButton/index.js'
import CollectionVersionsButton from '../elements/CollectionVersionsButton/index.js'
import { CustomPublishButton } from '../elements/CustomSaveButton/index.js'
import { draftWithMaxCollectionSlug } from '../slugs.js'
const DraftWithMaxPosts: CollectionConfig = {
slug: draftWithMaxCollectionSlug,
access: {
read: ({ req: { user } }) => {
if (user) {
return true
}
return {
or: [
{
_status: {
equals: 'published',
},
},
{
_status: {
exists: false,
},
},
],
}
},
readVersions: ({ req: { user } }) => Boolean(user),
},
admin: {
components: {
edit: {
PublishButton: CustomPublishButton,
},
views: {
Edit: {
Version: {
actions: [CollectionVersionButton],
},
Versions: {
actions: [CollectionVersionsButton],
},
},
},
},
defaultColumns: ['title', 'description', 'createdAt', '_status'],
preview: () => 'https://payloadcms.com',
useAsTitle: 'title',
},
fields: [
{
name: 'title',
type: 'text',
label: 'Title',
localized: true,
required: true,
unique: true,
},
{
name: 'description',
type: 'textarea',
label: 'Description',
required: true,
},
{
name: 'radio',
type: 'radio',
options: [
{
label: { en: 'Test en', es: 'Test es' },
value: 'test',
},
],
},
{
name: 'select',
type: 'select',
hasMany: true,
options: [
{
label: { en: 'Test1 en', es: 'Test1 es' },
value: 'test1',
},
{
label: { en: 'Test2 en', es: 'Test2 es' },
value: 'test2',
},
],
},
{
name: 'blocksField',
type: 'blocks',
blocks: [
{
slug: 'block',
fields: [
{
name: 'text',
type: 'text',
},
{
name: 'localized',
type: 'text',
localized: true,
},
],
},
],
},
{
name: 'relation',
type: 'relationship',
relationTo: draftWithMaxCollectionSlug,
},
],
versions: {
drafts: true,
maxPerDoc: 1,
},
}
export default DraftWithMaxPosts

View File

@@ -3,16 +3,26 @@ import AutosavePosts from './collections/Autosave.js'
import CustomIDs from './collections/CustomIDs.js'
import DisablePublish from './collections/DisablePublish.js'
import DraftPosts from './collections/Drafts.js'
import DraftWithMax from './collections/DraftsWithMax.js'
import Posts from './collections/Posts.js'
import VersionPosts from './collections/Versions.js'
import AutosaveGlobal from './globals/Autosave.js'
import DisablePublishGlobal from './globals/DisablePublish.js'
import DraftGlobal from './globals/Draft.js'
import DraftWithMaxGlobal from './globals/DraftWithMax.js'
import { seed } from './seed.js'
export default buildConfigWithDefaults({
collections: [DisablePublish, Posts, AutosavePosts, DraftPosts, VersionPosts, CustomIDs],
globals: [AutosaveGlobal, DraftGlobal, DisablePublishGlobal],
collections: [
DisablePublish,
Posts,
AutosavePosts,
DraftPosts,
DraftWithMax,
VersionPosts,
CustomIDs,
],
globals: [AutosaveGlobal, DraftGlobal, DraftWithMaxGlobal, DisablePublishGlobal],
indexSortableFields: true,
localization: {
defaultLocale: 'en',

View File

@@ -55,6 +55,8 @@ import {
disablePublishSlug,
draftCollectionSlug,
draftGlobalSlug,
draftWithMaxCollectionSlug,
draftWithMaxGlobalSlug,
postCollectionSlug,
} from './slugs.js'
@@ -352,6 +354,44 @@ describe('versions', () => {
expect(href).toBe(`${pathname}/versions`)
})
test('global — respects max number of versions', async () => {
await payload.updateGlobal({
slug: draftWithMaxGlobalSlug,
data: {
title: 'initial title',
},
})
const global = new AdminUrlUtil(serverURL, draftWithMaxGlobalSlug)
await page.goto(global.global(draftWithMaxGlobalSlug))
const titleFieldInitial = page.locator('#field-title')
await titleFieldInitial.fill('updated title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldInitial).toHaveValue('updated title')
const versionsTab = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const titleFieldUpdated = page.locator('#field-title')
await titleFieldUpdated.fill('latest title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldUpdated).toHaveValue('latest title')
const versionsTabUpdated = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTabUpdated.waitFor({ state: 'visible' })
expect(versionsTabUpdated).toBeTruthy()
})
test('global — has versions route', async () => {
const global = new AdminUrlUtil(serverURL, globalSlug)
const versionsURL = `${global.global(globalSlug)}/versions`
@@ -535,5 +575,45 @@ describe('versions', () => {
await expect(page.locator('.rs__option')).toHaveText('some title')
})
test('collection — respects max number of versions', async () => {
const maxOneCollection = await payload.create({
collection: draftWithMaxCollectionSlug,
data: {
title: 'initial title',
description: 'some description',
},
draft: true,
})
const collection = new AdminUrlUtil(serverURL, draftWithMaxCollectionSlug)
await page.goto(collection.edit(maxOneCollection.id))
const titleFieldInitial = page.locator('#field-title')
await titleFieldInitial.fill('updated title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldInitial).toHaveValue('updated title')
const versionsTab = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTab.waitFor({ state: 'visible' })
expect(versionsTab).toBeTruthy()
const titleFieldUpdated = page.locator('#field-title')
await titleFieldUpdated.fill('latest title')
await saveDocAndAssert(page, '#action-save-draft')
await expect(titleFieldUpdated).toHaveValue('latest title')
const versionsTabUpdated = page.locator('.doc-tab', {
hasText: '1',
})
await versionsTabUpdated.waitFor({ state: 'visible' })
expect(versionsTabUpdated).toBeTruthy()
})
})
})

View File

@@ -0,0 +1,61 @@
import type { GlobalConfig } from 'payload/types'
import GlobalVersionButton from '../elements/GlobalVersionButton/index.js'
import GlobalVersionsButton from '../elements/GlobalVersionsButton/index.js'
import { draftWithMaxGlobalSlug } from '../slugs.js'
const DraftWithMaxGlobal: GlobalConfig = {
slug: draftWithMaxGlobalSlug,
label: 'Draft Global',
admin: {
preview: () => 'https://payloadcms.com',
components: {
views: {
Edit: {
Version: {
actions: [GlobalVersionButton],
},
Versions: {
actions: [GlobalVersionsButton],
},
},
},
},
},
versions: {
max: 1,
drafts: true,
},
access: {
read: ({ req: { user } }) => {
if (user) {
return true
}
return {
or: [
{
_status: {
equals: 'published',
},
},
{
_status: {
exists: false,
},
},
],
}
},
},
fields: [
{
name: 'title',
type: 'text',
required: true,
localized: true,
},
],
}
export default DraftWithMaxGlobal

View File

@@ -12,6 +12,7 @@ export interface Config {
posts: Post;
'autosave-posts': AutosavePost;
'draft-posts': DraftPost;
'draft-with-max-posts': DraftWithMaxPost;
'version-posts': VersionPost;
'custom-ids': CustomId;
users: User;
@@ -21,6 +22,7 @@ export interface Config {
globals: {
'autosave-global': AutosaveGlobal;
'draft-global': DraftGlobal;
'draft-with-max-global': DraftWithMaxGlobal;
'disable-publish-global': DisablePublishGlobal;
};
locale: 'en' | 'es';
@@ -98,6 +100,30 @@ export interface DraftPost {
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "draft-with-max-posts".
*/
export interface DraftWithMaxPost {
id: string;
title: string;
description: string;
radio?: 'test' | null;
select?: ('test1' | 'test2')[] | null;
blocksField?:
| {
text?: string | null;
localized?: string | null;
id?: string | null;
blockName?: string | null;
blockType: 'block';
}[]
| null;
relation?: (string | null) | DraftWithMaxPost;
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "custom-ids".
@@ -181,6 +207,17 @@ export interface DraftGlobal {
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "draft-with-max-global".
*/
export interface DraftWithMaxGlobal {
id: string;
title: string;
_status?: ('draft' | 'published') | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "disable-publish-global".

View File

@@ -1,16 +1,17 @@
export const autosaveCollectionSlug = 'autosave-posts' as const
export const autosaveCollectionSlug = 'autosave-posts'
export const customIDSlug = 'custom-ids' as const
export const customIDSlug = 'custom-ids'
export const draftCollectionSlug = 'draft-posts' as const
export const draftCollectionSlug = 'draft-posts'
export const draftWithMaxCollectionSlug = 'draft-with-max-posts'
export const postCollectionSlug = 'posts' as const
export const postCollectionSlug = 'posts'
export const versionCollectionSlug = 'version-posts' as const
export const versionCollectionSlug = 'version-posts'
export const disablePublishSlug = 'disable-publish' as const
export const disablePublishSlug = 'disable-publish'
export const disablePublishGlobalSlug = 'disable-publish-global' as const
export const disablePublishGlobalSlug = 'disable-publish-global'
export const collectionSlugs = [
autosaveCollectionSlug,
@@ -19,7 +20,8 @@ export const collectionSlugs = [
versionCollectionSlug,
]
export const autoSaveGlobalSlug = 'autosave-global' as const
export const draftGlobalSlug = 'draft-global' as const
export const autoSaveGlobalSlug = 'autosave-global'
export const draftGlobalSlug = 'draft-global'
export const draftWithMaxGlobalSlug = 'draft-with-max-global'
export const globalSlugs = [autoSaveGlobalSlug, draftGlobalSlug]