Compare commits
6 Commits
chore/beta
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b95218577 | ||
|
|
a79d23c631 | ||
|
|
52c81ad525 | ||
|
|
8ec836737e | ||
|
|
e4a90294ea | ||
|
|
7c8d562f03 |
7
.vscode/launch.json
vendored
7
.vscode/launch.json
vendored
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.0.0-beta.42",
|
||||
"version": "3.0.0-beta.43",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() : []
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ const collectionSchema = joi.object().keys({
|
||||
interval: joi.number(),
|
||||
}),
|
||||
),
|
||||
validate: joi.boolean(),
|
||||
}),
|
||||
joi.boolean(),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
5
test/field-error-states/shared.ts
Normal file
5
test/field-error-states/shared.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const slugs = {
|
||||
validateDraftsOn: 'validate-drafts-on',
|
||||
validateDraftsOnAutosave: 'validate-drafts-on-autosave',
|
||||
validateDraftsOff: 'validate-drafts-off',
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
122
test/live-preview/helpers.ts
Normal file
122
test/live-preview/helpers.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -27,6 +27,17 @@ export default buildConfigWithDefaults({
|
||||
plugins: [
|
||||
redirectsPlugin({
|
||||
collections: ['pages'],
|
||||
overrides: {
|
||||
fields: ({ defaultFields }) => {
|
||||
return [
|
||||
...defaultFields,
|
||||
{
|
||||
type: 'text',
|
||||
name: 'customField',
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -11,10 +11,8 @@ import { Uploads1 } from './collections/Upload1/index.js'
|
||||
import { Uploads2 } from './collections/Upload2/index.js'
|
||||
import {
|
||||
audioSlug,
|
||||
cropOnlySlug,
|
||||
enlargeSlug,
|
||||
focalNoSizesSlug,
|
||||
globalWithMediaSlug,
|
||||
mediaSlug,
|
||||
reduceSlug,
|
||||
relationSlug,
|
||||
@@ -134,7 +132,7 @@ export default buildConfigWithDefaults({
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: cropOnlySlug,
|
||||
slug: 'crop-only',
|
||||
fields: [],
|
||||
upload: {
|
||||
focalPoint: false,
|
||||
@@ -464,18 +462,6 @@ export default buildConfigWithDefaults({
|
||||
},
|
||||
},
|
||||
],
|
||||
globals: [
|
||||
{
|
||||
slug: globalWithMediaSlug,
|
||||
fields: [
|
||||
{
|
||||
type: 'upload',
|
||||
name: 'media',
|
||||
relationTo: cropOnlySlug,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
onInit: async (payload) => {
|
||||
const uploadsDir = path.resolve(dirname, './media')
|
||||
removeFiles(path.normalize(uploadsDir))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { Payload } from 'payload/types'
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
import path from 'path'
|
||||
@@ -22,7 +23,6 @@ import {
|
||||
adminThumbnailFunctionSlug,
|
||||
adminThumbnailSizeSlug,
|
||||
audioSlug,
|
||||
globalWithMediaSlug,
|
||||
mediaSlug,
|
||||
relationSlug,
|
||||
} from './shared.js'
|
||||
@@ -39,7 +39,6 @@ let audioURL: AdminUrlUtil
|
||||
let relationURL: AdminUrlUtil
|
||||
let adminThumbnailSizeURL: AdminUrlUtil
|
||||
let adminThumbnailFunctionURL: AdminUrlUtil
|
||||
let globalWithMediaURL: string
|
||||
|
||||
describe('uploads', () => {
|
||||
let page: Page
|
||||
@@ -57,7 +56,6 @@ describe('uploads', () => {
|
||||
relationURL = new AdminUrlUtil(serverURL, relationSlug)
|
||||
adminThumbnailSizeURL = new AdminUrlUtil(serverURL, adminThumbnailSizeSlug)
|
||||
adminThumbnailFunctionURL = new AdminUrlUtil(serverURL, adminThumbnailFunctionSlug)
|
||||
globalWithMediaURL = new AdminUrlUtil(serverURL, globalWithMediaURL).global(globalWithMediaSlug)
|
||||
|
||||
const context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
@@ -375,17 +373,4 @@ describe('uploads', () => {
|
||||
expect(redDoc.filesize).toEqual(1207)
|
||||
})
|
||||
})
|
||||
|
||||
describe('globals', () => {
|
||||
test('should be able to crop media from a global', async () => {
|
||||
await page.goto(globalWithMediaURL)
|
||||
await page.click('.upload__toggler.doc-drawer__toggler')
|
||||
await page.setInputFiles('input[type="file"]', path.resolve(dirname, './image.png'))
|
||||
await page.click('.file-field__edit')
|
||||
await page.click('.btn.edit-upload__save')
|
||||
await saveDocAndAssert(page, '.drawer__content #action-save')
|
||||
await saveDocAndAssert(page)
|
||||
await expect(page.locator('.thumbnail img')).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,8 @@ export const audioSlug = 'audio'
|
||||
export const enlargeSlug = 'enlarge'
|
||||
export const focalNoSizesSlug = 'focal-no-sizes'
|
||||
export const focalOnlySlug = 'focal-only'
|
||||
export const cropOnlySlug = 'crop-only'
|
||||
export const reduceSlug = 'reduce'
|
||||
export const adminThumbnailFunctionSlug = 'admin-thumbnail-function'
|
||||
export const adminThumbnailSizeSlug = 'admin-thumbnail-size'
|
||||
export const unstoredMediaSlug = 'unstored-media'
|
||||
export const versionSlug = 'versions'
|
||||
export const globalWithMediaSlug = 'globals-with-media'
|
||||
|
||||
Reference in New Issue
Block a user