fix(richtext-lexical): Blocks: make sure fields are wrapped in a uniquely-named group, change block node data format, fix react key error (#3995)

* fix(richtext-lexical): make sure block fields are wrapped in a uniquely-named group

* chore: remove redundant hook

* chore(richtext-lexical): attempt to fix unnecessary unsaved changes warning regression

* cleanup everything

* chore: more cleanup

* debug

* looks like properly cloning the formdata for setting initial state fixes the issue where the old formdata is updated even if node.setFields is not called

* chore: fix e2e tests

* chore: fix e2e tests (a selector has changed)

* chore: fix int tests (due to new blocks data format)

* chore: fix incorrect insert block commands in drawer

* chore: add new e2e test

* chore: fail e2e tests when there are browser console errors

* fix(breaking): beforeInput and afterInput: fix missing key errors, consistent typing and cases in name
This commit is contained in:
Alessio Gravili
2023-11-16 22:01:04 +01:00
committed by GitHub
parent 989c10e0e0
commit c068a8784e
47 changed files with 682 additions and 438 deletions

View File

@@ -432,14 +432,14 @@ All Payload fields support the ability to swap in your own React components. So,
| **`Cell`** | Used in the `List` view's table to represent a table-based preview of the data stored in the field. [More](#cell-component) |
| **`Field`** | Swap out the field itself within all `Edit` views. [More](#field-component) |
As an alternative to replacing the entire Field component, you may want to keep the majority of the default Field component and only swap components within. This allows you to replace the **`Label`** or **`Error`** within a field component or add additional components inside the field with **`BeforeInput`** or **`AfterInput`**. **`BeforeInput`** and **`AfterInput`** are allowed in any fields that don't contain other fields, except [UI](/docs/fields/ui) and [Rich Text](/docs/fields/rich-text).
As an alternative to replacing the entire Field component, you may want to keep the majority of the default Field component and only swap components within. This allows you to replace the **`Label`** or **`Error`** within a field component or add additional components inside the field with **`beforeInput`** or **`afterInput`**. **`beforeInput`** and **`afterInput`** are allowed in any fields that don't contain other fields, except [UI](/docs/fields/ui) and [Rich Text](/docs/fields/rich-text).
| Component | Description |
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
| **`Label`** | Override the default Label in the Field Component. [More](#label-component) |
| **`Error`** | Override the default Label in the Field Component. [More](#error-component) |
| **`BeforeInput`** | An array of elements that will be added before `input`/`textarea` elements. [More](#afterinput-and-beforeinput) |
| **`AfterInput`** | An array of elements that will be added after `input`/`textarea` elements. [More](#afterinput-and-beforeinput) |
| **`beforeInput`** | An array of elements that will be added before `input`/`textarea` elements. [More](#afterinput-and-beforeinput) |
| **`afterInput`** | An array of elements that will be added after `input`/`textarea` elements. [More](#afterinput-and-beforeinput) |
## Cell Component
@@ -564,7 +564,7 @@ const CustomError: React.FC<Props> = (props) => {
}
```
## AfterInput and BeforeInput
## afterInput and beforeInput
With these properties you can add multiple components before and after the input element. For example, you can add an absolutely positioned button to clear the current field value.
@@ -583,9 +583,7 @@ const fieldField: Field = {
type: 'text',
admin: {
components: {
AfterInput: [
<ClearButton />
]
afterInput: [ClearButton]
}
}
}

View File

@@ -10,10 +10,10 @@ import './index.scss'
const baseClass = 'checkbox-input'
type CheckboxInputProps = {
AfterInput?: React.ReactElement<any>[]
BeforeInput?: React.ReactElement<any>[]
Label?: React.ComponentType<LabelProps>
afterInput?: React.ComponentType<any>[]
'aria-label'?: string
beforeInput?: React.ComponentType<any>[]
checked?: boolean
className?: string
id?: string
@@ -30,10 +30,10 @@ export const CheckboxInput: React.FC<CheckboxInputProps> = (props) => {
const {
id,
name,
AfterInput,
BeforeInput,
Label,
afterInput,
'aria-label': ariaLabel,
beforeInput,
checked,
className,
inputRef,
@@ -58,7 +58,7 @@ export const CheckboxInput: React.FC<CheckboxInputProps> = (props) => {
.join(' ')}
>
<div className={`${baseClass}__input`}>
{BeforeInput}
{Array.isArray(beforeInput) && beforeInput.map((Component, i) => <Component key={i} />)}
<input
aria-label={ariaLabel}
defaultChecked={Boolean(checked)}
@@ -69,7 +69,7 @@ export const CheckboxInput: React.FC<CheckboxInputProps> = (props) => {
ref={inputRef}
type="checkbox"
/>
{AfterInput}
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
<span className={`${baseClass}__icon ${!partialChecked ? 'check' : 'partial'}`}>
{!partialChecked && <Check />}
{partialChecked && <Line />}

View File

@@ -20,12 +20,12 @@ const Checkbox: React.FC<Props> = (props) => {
name,
admin: {
className,
components: { Error, Label, afterInput, beforeInput } = {},
condition,
description,
readOnly,
style,
width,
components: { Error, Label, BeforeInput, AfterInput } = {},
} = {},
disableFormData,
label,
@@ -85,15 +85,15 @@ const Checkbox: React.FC<Props> = (props) => {
<ErrorComp alignCaret="left" message={errorMessage} showError={showError} />
</div>
<CheckboxInput
Label={Label}
afterInput={afterInput}
beforeInput={beforeInput}
checked={Boolean(value)}
id={fieldID}
label={getTranslation(label || name, i18n)}
name={path}
onToggle={onToggle}
readOnly={readOnly}
Label={Label}
BeforeInput={BeforeInput}
AfterInput={AfterInput}
required={required}
/>
<FieldDescription description={description} value={value} />

View File

@@ -9,8 +9,8 @@ import FieldDescription from '../../FieldDescription'
import DefaultLabel from '../../Label'
import useField from '../../useField'
import withCondition from '../../withCondition'
import './index.scss'
import { fieldBaseClass } from '../shared'
import './index.scss'
const prismToMonacoLanguageMap = {
js: 'javascript',
@@ -24,6 +24,7 @@ const Code: React.FC<Props> = (props) => {
name,
admin: {
className,
components: { Error, Label } = {},
condition,
description,
editorOptions,
@@ -31,7 +32,6 @@ const Code: React.FC<Props> = (props) => {
readOnly,
style,
width,
components: { Error, Label } = {},
} = {},
label,
path: pathFromProps,

View File

@@ -17,10 +17,10 @@ const baseClass = 'date-time-field'
export type DateTimeInputProps = Omit<DateField, 'admin' | 'name' | 'type'> & {
className?: string
components: {
AfterInput?: React.ReactElement<any>[]
BeforeInput?: React.ReactElement<any>[]
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
afterInput?: React.ComponentType<any>[]
beforeInput?: React.ComponentType<any>[]
}
datePickerProps?: DateField['admin']['date']
description?: Description
@@ -39,7 +39,7 @@ export type DateTimeInputProps = Omit<DateField, 'admin' | 'name' | 'type'> & {
export const DateTimeInput: React.FC<DateTimeInputProps> = (props) => {
const {
className,
components: { AfterInput, BeforeInput, Error, Label } = {},
components: { Error, Label, afterInput, beforeInput } = {},
datePickerProps,
description,
errorMessage,
@@ -81,7 +81,7 @@ export const DateTimeInput: React.FC<DateTimeInputProps> = (props) => {
</div>
<LabelComp htmlFor={path} label={label} required={required} />
<div className={`${baseClass}__input-wrapper`} id={`field-${path.replace(/\./g, '__')}`}>
{BeforeInput}
{Array.isArray(beforeInput) && beforeInput.map((Component, i) => <Component key={i} />)}
<DatePicker
{...datePickerProps}
onChange={onChange}
@@ -89,7 +89,7 @@ export const DateTimeInput: React.FC<DateTimeInputProps> = (props) => {
readOnly={readOnly}
value={value}
/>
{AfterInput}
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
</div>
<FieldDescription description={description} value={value} />
</div>

View File

@@ -10,8 +10,8 @@ import FieldDescription from '../../FieldDescription'
import DefaultLabel from '../../Label'
import useField from '../../useField'
import withCondition from '../../withCondition'
import './index.scss'
import { fieldBaseClass } from '../shared'
import './index.scss'
const Email: React.FC<Props> = (props) => {
const {
@@ -19,13 +19,13 @@ const Email: React.FC<Props> = (props) => {
admin: {
autoComplete,
className,
components: { Error, Label, afterInput, beforeInput } = {},
condition,
description,
placeholder,
readOnly,
style,
width,
components: { Error, Label, BeforeInput, AfterInput } = {},
} = {},
label,
path: pathFromProps,
@@ -68,7 +68,7 @@ const Email: React.FC<Props> = (props) => {
<ErrorComp message={errorMessage} showError={showError} />
<LabelComp htmlFor={`field-${path.replace(/\./g, '__')}`} label={label} required={required} />
<div className="input-wrapper">
{BeforeInput}
{Array.isArray(beforeInput) && beforeInput.map((Component, i) => <Component key={i} />)}
<input
autoComplete={autoComplete}
disabled={Boolean(readOnly)}
@@ -79,7 +79,7 @@ const Email: React.FC<Props> = (props) => {
type="email"
value={(value as string) || ''}
/>
{AfterInput}
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
</div>
<FieldDescription description={description} value={value} />
</div>

View File

@@ -13,14 +13,15 @@ import FieldDescription from '../../FieldDescription'
import DefaultLabel from '../../Label'
import useField from '../../useField'
import withCondition from '../../withCondition'
import './index.scss'
import { fieldBaseClass } from '../shared'
import './index.scss'
const NumberField: React.FC<Props> = (props) => {
const {
name,
admin: {
className,
components: { Error, Label, afterInput, beforeInput } = {},
condition,
description,
placeholder,
@@ -28,7 +29,6 @@ const NumberField: React.FC<Props> = (props) => {
step,
style,
width,
components: { Error, Label, BeforeInput, AfterInput } = {},
} = {},
hasMany,
label,
@@ -162,7 +162,7 @@ const NumberField: React.FC<Props> = (props) => {
/>
) : (
<div className="input-wrapper">
{BeforeInput}
{Array.isArray(beforeInput) && beforeInput.map((Component, i) => <Component key={i} />)}
<input
disabled={readOnly}
id={`field-${path.replace(/\./g, '__')}`}
@@ -178,7 +178,7 @@ const NumberField: React.FC<Props> = (props) => {
type="number"
value={typeof value === 'number' ? value : ''}
/>
{AfterInput}
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
</div>
)}

View File

@@ -10,8 +10,8 @@ import FieldDescription from '../../FieldDescription'
import DefaultLabel from '../../Label'
import useField from '../../useField'
import withCondition from '../../withCondition'
import './index.scss'
import { fieldBaseClass } from '../shared'
import './index.scss'
const baseClass = 'point'
@@ -20,6 +20,7 @@ const PointField: React.FC<Props> = (props) => {
name,
admin: {
className,
components: { Error, Label, afterInput, beforeInput } = {},
condition,
description,
placeholder,
@@ -27,7 +28,6 @@ const PointField: React.FC<Props> = (props) => {
step,
style,
width,
components: { Error, Label, BeforeInput, AfterInput } = {},
} = {},
label,
path: pathFromProps,
@@ -98,7 +98,7 @@ const PointField: React.FC<Props> = (props) => {
required={required}
/>
<div className="input-wrapper">
{BeforeInput}
{Array.isArray(beforeInput) && beforeInput.map((Component, i) => <Component key={i} />)}
<input
disabled={readOnly}
id={`field-longitude-${path.replace(/\./g, '__')}`}
@@ -109,7 +109,7 @@ const PointField: React.FC<Props> = (props) => {
type="number"
value={value && typeof value[0] === 'number' ? value[0] : ''}
/>
{AfterInput}
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
</div>
</li>
<li>
@@ -119,7 +119,7 @@ const PointField: React.FC<Props> = (props) => {
required={required}
/>
<div className="input-wrapper">
{BeforeInput}
{Array.isArray(beforeInput) && beforeInput.map((Component, i) => <Component key={i} />)}
<input
disabled={readOnly}
id={`field-latitude-${path.replace(/\./g, '__')}`}
@@ -130,7 +130,7 @@ const PointField: React.FC<Props> = (props) => {
type="number"
value={value && typeof value[1] === 'number' ? value[1] : ''}
/>
{AfterInput}
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
</div>
</li>
</ul>

View File

@@ -14,6 +14,10 @@ import { fieldBaseClass } from '../shared'
import './index.scss'
export type TextInputProps = Omit<TextField, 'type'> & {
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
afterInput?: React.ComponentType<any>[]
beforeInput?: React.ComponentType<any>[]
className?: string
description?: Description
errorMessage?: string
@@ -29,14 +33,14 @@ export type TextInputProps = Omit<TextField, 'type'> & {
style?: React.CSSProperties
value?: string
width?: string
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
BeforeInput?: React.ReactElement<any>[]
AfterInput?: React.ReactElement<any>[]
}
const TextInput: React.FC<TextInputProps> = (props) => {
const {
Error,
Label,
afterInput,
beforeInput,
className,
description,
errorMessage,
@@ -53,10 +57,6 @@ const TextInput: React.FC<TextInputProps> = (props) => {
style,
value,
width,
Error,
Label,
BeforeInput,
AfterInput,
} = props
const { i18n } = useTranslation()
@@ -77,7 +77,7 @@ const TextInput: React.FC<TextInputProps> = (props) => {
<ErrorComp message={errorMessage} showError={showError} />
<LabelComp htmlFor={`field-${path.replace(/\./g, '__')}`} label={label} required={required} />
<div className="input-wrapper">
{BeforeInput}
{Array.isArray(beforeInput) && beforeInput.map((Component, i) => <Component key={i} />)}
<input
data-rtl={rtl}
disabled={readOnly}
@@ -90,7 +90,7 @@ const TextInput: React.FC<TextInputProps> = (props) => {
type="text"
value={value || ''}
/>
{AfterInput}
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
</div>
<FieldDescription
className={`field-description-${path.replace(/\./g, '__')}`}

View File

@@ -15,6 +15,7 @@ const Text: React.FC<Props> = (props) => {
name,
admin: {
className,
components: { Error, Label, afterInput, beforeInput } = {},
condition,
description,
placeholder,
@@ -22,7 +23,6 @@ const Text: React.FC<Props> = (props) => {
rtl,
style,
width,
components: { Error, Label, BeforeInput, AfterInput } = {},
} = {},
inputRef,
label,
@@ -60,6 +60,10 @@ const Text: React.FC<Props> = (props) => {
return (
<TextInput
Error={Error}
Label={Label}
afterInput={afterInput}
beforeInput={beforeInput}
className={className}
description={description}
errorMessage={errorMessage}
@@ -78,10 +82,6 @@ const Text: React.FC<Props> = (props) => {
style={style}
value={value}
width={width}
Error={Error}
Label={Label}
BeforeInput={BeforeInput}
AfterInput={AfterInput}
/>
)
}

View File

@@ -10,10 +10,14 @@ import { getTranslation } from '../../../../../utilities/getTranslation'
import DefaultError from '../../Error'
import FieldDescription from '../../FieldDescription'
import DefaultLabel from '../../Label'
import './index.scss'
import { fieldBaseClass } from '../shared'
import './index.scss'
export type TextAreaInputProps = Omit<TextareaField, 'type'> & {
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
afterInput?: React.ComponentType<any>[]
beforeInput?: React.ComponentType<any>[]
className?: string
description?: Description
errorMessage?: string
@@ -28,14 +32,14 @@ export type TextAreaInputProps = Omit<TextareaField, 'type'> & {
style?: React.CSSProperties
value?: string
width?: string
Error?: React.ComponentType<any>
Label?: React.ComponentType<any>
BeforeInput?: React.ReactElement<any>[]
AfterInput?: React.ReactElement<any>[]
}
const TextareaInput: React.FC<TextAreaInputProps> = (props) => {
const {
Error,
Label,
afterInput,
beforeInput,
className,
description,
errorMessage,
@@ -51,10 +55,6 @@ const TextareaInput: React.FC<TextAreaInputProps> = (props) => {
style,
value,
width,
Error,
Label,
BeforeInput,
AfterInput,
} = props
const { i18n } = useTranslation()
@@ -83,7 +83,7 @@ const TextareaInput: React.FC<TextAreaInputProps> = (props) => {
<label className="textarea-outer" htmlFor={`field-${path.replace(/\./g, '__')}`}>
<div className="textarea-inner">
<div className="textarea-clone" data-value={value || placeholder || ''} />
{BeforeInput}
{Array.isArray(beforeInput) && beforeInput.map((Component, i) => <Component key={i} />)}
<textarea
className="textarea-element"
data-rtl={rtl}
@@ -95,7 +95,7 @@ const TextareaInput: React.FC<TextAreaInputProps> = (props) => {
rows={rows}
value={value || ''}
/>
{AfterInput}
{Array.isArray(afterInput) && afterInput.map((Component, i) => <Component key={i} />)}
</div>
</label>
<FieldDescription description={description} value={value} />

View File

@@ -18,6 +18,7 @@ const Textarea: React.FC<Props> = (props) => {
name,
admin: {
className,
components: { Error, Label, afterInput, beforeInput } = {},
condition,
description,
placeholder,
@@ -26,7 +27,6 @@ const Textarea: React.FC<Props> = (props) => {
rtl,
style,
width,
components: { Error, Label, BeforeInput, AfterInput } = {},
} = {},
label,
localized,
@@ -65,6 +65,10 @@ const Textarea: React.FC<Props> = (props) => {
return (
<TextareaInput
Error={Error}
Label={Label}
afterInput={afterInput}
beforeInput={beforeInput}
className={className}
description={description}
errorMessage={errorMessage}
@@ -83,10 +87,6 @@ const Textarea: React.FC<Props> = (props) => {
style={style}
value={value as string}
width={width}
Error={Error}
Label={Label}
BeforeInput={BeforeInput}
AfterInput={AfterInput}
/>
)
}

View File

@@ -25,13 +25,15 @@ export const ToolbarControls: React.FC<EditViewProps> = () => {
{breakpoints?.length > 0 && (
<Popup
className={`${baseClass}__breakpoint`}
button={(
button={
<>
<span>{breakpoints.find(bp => bp.name == breakpoint)?.label ?? customOption.label}</span>
<span>
{breakpoints.find((bp) => bp.name == breakpoint)?.label ?? customOption.label}
</span>
&nbsp;
<Chevron className={`${baseClass}__chevron`} />
</>
)}
}
render={({ close }) => (
<PopupList.ButtonGroup>
<React.Fragment>
@@ -49,10 +51,13 @@ export const ToolbarControls: React.FC<EditViewProps> = () => {
))}
{/* Dynamically add this option so that it only appears when the width and height inputs are explicitly changed */}
{breakpoint === 'custom' && (
<PopupList.Button active={breakpoint == customOption.value} onClick={() => {
<PopupList.Button
active={breakpoint == customOption.value}
onClick={() => {
setBreakpoint(customOption.value)
close()
}}>
}}
>
{customOption.label}
</PopupList.Button>
)}
@@ -73,20 +78,20 @@ export const ToolbarControls: React.FC<EditViewProps> = () => {
</div>
<Popup
className={`${baseClass}__zoom`}
button={(
button={
<>
<span>{zoom * 100}%</span>
&nbsp;
<Chevron className={`${baseClass}__chevron`} />
</>
)}
}
render={({ close }) => (
<PopupList.ButtonGroup>
<React.Fragment>
{zoomOptions.map((zoomValue) => (
<PopupList.Button
key={zoomValue}
active={(zoom*100) == zoomValue}
active={zoom * 100 == zoomValue}
onClick={() => {
setZoom(zoomValue / 100)
close()

View File

@@ -71,16 +71,16 @@ export const text = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
autoComplete: joi.string(),
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
placeholder: joi
.alternatives()
.try(joi.object().pattern(joi.string(), [joi.string()]), joi.string()),
rtl: joi.boolean(),
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
BeforeInput: joi.array().items(componentSchema),
AfterInput: joi.array().items(componentSchema),
}),
}),
defaultValue: joi.alternatives().try(joi.string(), joi.func()),
maxLength: joi.number(),
@@ -92,20 +92,20 @@ export const number = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
autoComplete: joi.string(),
placeholder: joi.string(),
step: joi.number(),
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
BeforeInput: joi
Label: componentSchema,
afterInput: joi
.array()
.items(componentSchema)
.when('hasMany', { not: true, otherwise: joi.forbidden() }),
AfterInput: joi
beforeInput: joi
.array()
.items(componentSchema)
.when('hasMany', { not: true, otherwise: joi.forbidden() }),
}),
placeholder: joi.string(),
step: joi.number(),
}),
defaultValue: joi.alternatives().try(joi.number(), joi.func()),
hasMany: joi.boolean().default(false),
@@ -119,15 +119,15 @@ export const number = baseField.keys({
export const textarea = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
placeholder: joi.string(),
rows: joi.number(),
rtl: joi.boolean(),
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
BeforeInput: joi.array().items(componentSchema),
AfterInput: joi.array().items(componentSchema),
}),
}),
defaultValue: joi.alternatives().try(joi.string(), joi.func()),
maxLength: joi.number(),
@@ -139,13 +139,13 @@ export const email = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
autoComplete: joi.string(),
placeholder: joi.string(),
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
BeforeInput: joi.array().items(componentSchema),
AfterInput: joi.array().items(componentSchema),
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
placeholder: joi.string(),
}),
defaultValue: joi.alternatives().try(joi.string(), joi.func()),
maxLength: joi.number(),
@@ -156,12 +156,12 @@ export const email = baseField.keys({
export const code = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
}),
editorOptions: joi.object().unknown(), // Editor['options'] @monaco-editor/react
language: joi.string(),
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
}),
}),
defaultValue: joi.alternatives().try(joi.string(), joi.func()),
type: joi.string().valid('code').required(),
@@ -171,8 +171,8 @@ export const json = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
Label: componentSchema,
}),
}),
defaultValue: joi.alternatives().try(joi.array(), joi.object()),
@@ -182,12 +182,12 @@ export const json = baseField.keys({
export const select = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
}),
isClearable: joi.boolean().default(false),
isSortable: joi.boolean().default(false),
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
}),
}),
defaultValue: joi
.alternatives()
@@ -214,11 +214,11 @@ export const select = baseField.keys({
export const radio = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
layout: joi.string().valid('vertical', 'horizontal'),
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
Label: componentSchema,
}),
layout: joi.string().valid('vertical', 'horizontal'),
}),
defaultValue: joi.alternatives().try(joi.string().allow(''), joi.func()),
options: joi
@@ -318,8 +318,8 @@ export const upload = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
Label: componentSchema,
}),
}),
defaultValue: joi.alternatives().try(joi.object(), joi.func()),
@@ -333,10 +333,10 @@ export const checkbox = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
BeforeInput: joi.array().items(componentSchema),
AfterInput: joi.array().items(componentSchema),
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
}),
defaultValue: joi.alternatives().try(joi.boolean(), joi.func()),
@@ -347,10 +347,10 @@ export const point = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
BeforeInput: joi.array().items(componentSchema),
AfterInput: joi.array().items(componentSchema),
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
}),
defaultValue: joi.alternatives().try(joi.array().items(joi.number()).max(2).min(2), joi.func()),
@@ -361,11 +361,11 @@ export const relationship = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
allowCreate: joi.boolean().default(true),
isSortable: joi.boolean().default(false),
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
Label: componentSchema,
}),
isSortable: joi.boolean().default(false),
}),
defaultValue: joi.alternatives().try(joi.func()),
filterOptions: joi.alternatives().try(joi.object(), joi.func()),
@@ -445,6 +445,12 @@ export const richText = baseField.keys({
export const date = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.keys({
components: baseAdminComponentFields.keys({
Error: componentSchema,
Label: componentSchema,
afterInput: joi.array().items(componentSchema),
beforeInput: joi.array().items(componentSchema),
}),
date: joi.object({
displayFormat: joi.string(),
maxDate: joi.date(),
@@ -458,12 +464,6 @@ export const date = baseField.keys({
timeIntervals: joi.number(),
}),
placeholder: joi.string(),
components: baseAdminComponentFields.keys({
Label: componentSchema,
Error: componentSchema,
BeforeInput: joi.array().items(componentSchema),
AfterInput: joi.array().items(componentSchema),
}),
}),
defaultValue: joi.alternatives().try(joi.string(), joi.func()),
type: joi.string().valid('date').required(),

View File

@@ -4,8 +4,12 @@ import type { TFunction } from 'i18next'
import type { CSSProperties } from 'react'
import monacoeditor from 'monaco-editor' // IMPORTANT - DO NOT REMOVE: This is required for pnpm's default isolated mode to work - even though the import is not used. This is due to a typescript bug: https://github.com/microsoft/TypeScript/issues/47663#issuecomment-1519138189. (tsbugisolatedmode)
import type React from 'react'
import type { ConditionalDateProps } from '../../admin/components/elements/DatePicker/types'
import type { Props as ErrorProps } from '../../admin/components/forms/Error/types'
import type { Description } from '../../admin/components/forms/FieldDescription/types'
import type { Props as LabelProps } from '../../admin/components/forms/Label/types'
import type { RowLabel } from '../../admin/components/forms/RowLabel/types'
import type { RichTextAdapter } from '../../admin/components/forms/field-types/RichText/types'
import type { User } from '../../auth'
@@ -15,8 +19,6 @@ import type { PayloadRequest, RequestContext } from '../../express/types'
import type { SanitizedGlobalConfig } from '../../globals/config/types'
import type { Payload } from '../../payload'
import type { Operation, Where } from '../../types'
import type { Props as ErrorProps } from '../../admin/components/forms/Error/types'
import type { Props as LabelProps } from '../../admin/components/forms/Label/types'
export type FieldHookArgs<T extends TypeWithID = any, P = any, S = any> = {
/** The collection which the field belongs to. If the field belongs to a global, this will be null. */
@@ -170,16 +172,16 @@ export type NumberField = FieldBase & {
admin?: Admin & {
/** Set this property to a string that will be used for browser autocomplete. */
autoComplete?: string
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
afterInput?: React.ComponentType<any>[]
beforeInput?: React.ComponentType<any>[]
}
/** Set this property to define a placeholder string for the field. */
placeholder?: Record<string, string> | string
/** Set a value for the number field to increment / decrement using browser controls. */
step?: number
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
BeforeInput?: React.ReactElement<any>[]
AfterInput?: React.ReactElement<any>[]
}
}
/** Maximum value accepted. Used in the default `validation` function. */
max?: number
@@ -208,14 +210,14 @@ export type NumberField = FieldBase & {
export type TextField = FieldBase & {
admin?: Admin & {
autoComplete?: string
placeholder?: Record<string, string> | string
rtl?: boolean
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
BeforeInput?: React.ReactElement<any>[]
AfterInput?: React.ReactElement<any>[]
afterInput?: React.ComponentType<any>[]
beforeInput?: React.ComponentType<any>[]
}
placeholder?: Record<string, string> | string
rtl?: boolean
}
maxLength?: number
minLength?: number
@@ -225,28 +227,28 @@ export type TextField = FieldBase & {
export type EmailField = FieldBase & {
admin?: Admin & {
autoComplete?: string
placeholder?: Record<string, string> | string
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
BeforeInput?: React.ReactElement<any>[]
AfterInput?: React.ReactElement<any>[]
afterInput?: React.ComponentType<any>[]
beforeInput?: React.ComponentType<any>[]
}
placeholder?: Record<string, string> | string
}
type: 'email'
}
export type TextareaField = FieldBase & {
admin?: Admin & {
placeholder?: Record<string, string> | string
rows?: number
rtl?: boolean
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
BeforeInput?: React.ReactElement<any>[]
AfterInput?: React.ReactElement<any>[]
afterInput?: React.ComponentType<any>[]
beforeInput?: React.ComponentType<any>[]
}
placeholder?: Record<string, string> | string
rows?: number
rtl?: boolean
}
maxLength?: number
minLength?: number
@@ -254,27 +256,27 @@ export type TextareaField = FieldBase & {
}
export type CheckboxField = FieldBase & {
type: 'checkbox'
admin?: Admin & {
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
BeforeInput?: React.ReactElement<any>[]
AfterInput?: React.ReactElement<any>[]
afterInput?: React.ComponentType<any>[]
beforeInput?: React.ComponentType<any>[]
}
}
type: 'checkbox'
}
export type DateField = FieldBase & {
admin?: Admin & {
date?: ConditionalDateProps
placeholder?: Record<string, string> | string
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
BeforeInput?: React.ReactElement<any>[]
AfterInput?: React.ReactElement<any>[]
afterInput?: React.ComponentType<any>[]
beforeInput?: React.ComponentType<any>[]
}
date?: ConditionalDateProps
placeholder?: Record<string, string> | string
}
type: 'date'
}
@@ -382,12 +384,12 @@ export type UploadField = FieldBase & {
}
type CodeAdmin = Admin & {
editorOptions?: EditorProps['options']
language?: string
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
}
editorOptions?: EditorProps['options']
language?: string
}
export type CodeField = Omit<FieldBase, 'admin'> & {
@@ -398,11 +400,11 @@ export type CodeField = Omit<FieldBase, 'admin'> & {
}
type JSONAdmin = Admin & {
editorOptions?: EditorProps['options']
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
}
editorOptions?: EditorProps['options']
}
export type JSONField = Omit<FieldBase, 'admin'> & {
@@ -412,12 +414,12 @@ export type JSONField = Omit<FieldBase, 'admin'> & {
export type SelectField = FieldBase & {
admin?: Admin & {
isClearable?: boolean
isSortable?: boolean
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
}
isClearable?: boolean
isSortable?: boolean
}
hasMany?: boolean
options: Option[]
@@ -427,11 +429,11 @@ export type SelectField = FieldBase & {
export type RelationshipField = FieldBase & {
admin?: Admin & {
allowCreate?: boolean
isSortable?: boolean
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
}
isSortable?: boolean
}
filterOptions?: FilterOptions
hasMany?: boolean
@@ -515,11 +517,11 @@ export type ArrayField = FieldBase & {
export type RadioField = FieldBase & {
admin?: Admin & {
layout?: 'horizontal' | 'vertical'
components?: {
Error?: React.ComponentType<ErrorProps>
Label?: React.ComponentType<LabelProps>
}
layout?: 'horizontal' | 'vertical'
}
options: Option[]
type: 'radio'

View File

@@ -1,4 +1,4 @@
import type { Block, Data, Fields } from 'payload/types'
import type { Block, Data, Field, Fields } from 'payload/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import isDeepEqual from 'deep-equal'
@@ -9,7 +9,7 @@ import { SectionTitle } from 'payload/components/fields/Blocks'
import { RenderFields, createNestedFieldPath, useFormSubmitted } from 'payload/components/forms'
import { useDocumentInfo } from 'payload/components/utilities'
import { getTranslation } from 'payload/utilities'
import React, { useCallback } from 'react'
import React, { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import type { FieldProps } from '../../../../types'
@@ -21,7 +21,8 @@ type Props = {
baseClass: string
block: Block
field: FieldProps
fields: BlockFields
formData: BlockFields
formSchema: Field[]
nodeKey: string
}
@@ -31,7 +32,14 @@ type Props = {
* not the whole document.
*/
export const BlockContent: React.FC<Props> = (props) => {
const { baseClass, block, field, fields, nodeKey } = props
const {
baseClass,
block: { labels },
field,
formData,
formSchema,
nodeKey,
} = props
const { i18n } = useTranslation()
const [editor] = useLexicalComposerContext()
// Used for saving collapsed to preferences (and gettin' it from there again)
@@ -47,9 +55,9 @@ export const BlockContent: React.FC<Props> = (props) => {
const collapsedMap: { [key: string]: boolean } = currentFieldPreferences?.collapsed
if (collapsedMap && collapsedMap[fields.data.id] !== undefined) {
setCollapsed(collapsedMap[fields.data.id])
initialState = collapsedMap[fields.data.id]
if (collapsedMap && collapsedMap[formData.id] !== undefined) {
setCollapsed(collapsedMap[formData.id])
initialState = collapsedMap[formData.id]
}
})
return initialState
@@ -70,13 +78,19 @@ export const BlockContent: React.FC<Props> = (props) => {
const path = '' as const
const onFormChange = useCallback(
({ fields: formFields, formData }: { fields: Fields; formData: Data }) => {
({
fullFieldsWithValues,
newFormData,
}: {
fullFieldsWithValues: Fields
newFormData: Data
}) => {
// Recursively remove all undefined values from even being present in formData, as they will
// cause isDeepEqual to return false if, for example, formData has a key that fields.data
// does not have, even if it's undefined.
// Currently, this happens if a block has another sub-blocks field. Inside of formData, that sub-blocks field has an undefined blockName property.
// Inside of fields.data however, that sub-blocks blockName property does not exist at all.
function removeUndefinedRecursively(obj: any) {
function removeUndefinedRecursively(obj: object) {
Object.keys(obj).forEach((key) => {
if (obj[key] && typeof obj[key] === 'object') {
removeUndefinedRecursively(obj[key])
@@ -85,12 +99,12 @@ export const BlockContent: React.FC<Props> = (props) => {
}
})
}
removeUndefinedRecursively(newFormData)
removeUndefinedRecursively(formData)
removeUndefinedRecursively(fields.data)
// Only update if the data has actually changed. Otherwise, we may be triggering an unnecessary value change,
// which would trigger the "Leave without saving" dialog unnecessarily
if (!isDeepEqual(fields.data, formData)) {
if (!isDeepEqual(formData, newFormData)) {
// Running this in the next tick in the meantime fixes this issue: https://github.com/payloadcms/payload/issues/4108
// I don't know why. When this is called immediately, it might focus out of a nested lexical editor field if an update is made there.
// My hypothesis is that the nested editor might not have fully finished its update cycle yet. By updating in the next tick, we
@@ -99,9 +113,7 @@ export const BlockContent: React.FC<Props> = (props) => {
editor.update(() => {
const node: BlockNode = $getNodeByKey(nodeKey)
if (node) {
node.setFields({
data: formData as any,
})
node.setFields(newFormData as BlockFields)
}
})
}, 0)
@@ -110,7 +122,7 @@ export const BlockContent: React.FC<Props> = (props) => {
// update error count
if (hasSubmitted) {
let rowErrorCount = 0
for (const formField of Object.values(formFields)) {
for (const formField of Object.values(fullFieldsWithValues)) {
if (formField?.valid === false) {
rowErrorCount++
}
@@ -118,7 +130,7 @@ export const BlockContent: React.FC<Props> = (props) => {
setErrorCount(rowErrorCount)
}
},
[editor, nodeKey, hasSubmitted],
[editor, nodeKey, hasSubmitted, formData],
)
const onCollapsedChange = useCallback(() => {
@@ -130,13 +142,13 @@ export const BlockContent: React.FC<Props> = (props) => {
const newCollapsed: { [key: string]: boolean } =
collapsedMap && collapsedMap?.size ? collapsedMap : {}
newCollapsed[fields.data.id] = !collapsed
newCollapsed[formData.id] = !collapsed
setDocFieldPreferences(field.name, {
collapsed: newCollapsed,
})
})
}, [collapsed, getDocPreferences, field.name, setDocFieldPreferences, fields.data.id])
}, [collapsed, getDocPreferences, field.name, setDocFieldPreferences, formData.id])
const removeBlock = useCallback(() => {
editor.update(() => {
@@ -144,6 +156,11 @@ export const BlockContent: React.FC<Props> = (props) => {
})
}, [editor, nodeKey])
const fieldSchemaWithPath = formSchema.map((field) => ({
...field,
path: createNestedFieldPath(null, field),
}))
return (
<React.Fragment>
<Collapsible
@@ -154,10 +171,10 @@ export const BlockContent: React.FC<Props> = (props) => {
<div className={`${baseClass}__block-header`}>
<div>
<Pill
className={`${baseClass}__block-pill ${baseClass}__block-pill-${fields?.data?.blockType}`}
className={`${baseClass}__block-pill ${baseClass}__block-pill-${formData?.blockType}`}
pillStyle="white"
>
{getTranslation(block.labels.singular, i18n)}
{getTranslation(labels.singular, i18n)}
</Pill>
<SectionTitle path={`${path}blockName`} readOnly={field?.admin?.readOnly} />
{fieldHasErrors && <ErrorPill count={errorCount} withMessage />}
@@ -186,25 +203,16 @@ export const BlockContent: React.FC<Props> = (props) => {
>
<RenderFields
className={`${baseClass}__fields`}
fieldSchema={block.fields.map((field) => ({
...field,
path: createNestedFieldPath(null, field),
}))}
fieldSchema={fieldSchemaWithPath}
fieldTypes={field.fieldTypes}
forceRender
margins="small"
permissions={field.permissions?.blocks?.[fields?.data?.blockType]?.fields}
permissions={field.permissions?.blocks?.[formData?.blockType]?.fields}
readOnly={field.admin.readOnly}
/>
</Collapsible>
<FormSavePlugin
fieldSchema={block.fields.map((field) => ({
...field,
path: createNestedFieldPath(null, field),
}))}
onChange={onFormChange}
/>
<FormSavePlugin onChange={onFormChange} />
</React.Fragment>
)
}

View File

@@ -1,14 +1,17 @@
import type { Data, FieldWithPath, Fields } from 'payload/types'
import type { Data, Fields } from 'payload/types'
import type React from 'react'
import { reduceFieldsToValues, useAllFormFields } from 'payload/components/forms'
import { useEffect } from 'react'
import './index.scss'
type Props = {
fieldSchema: FieldWithPath[]
onChange?: ({ fields, formData }: { fields: Fields; formData: Data }) => void
onChange?: ({
fullFieldsWithValues,
newFormData,
}: {
fullFieldsWithValues: Fields
newFormData: Data
}) => void
}
export const FormSavePlugin: React.FC<Props> = (props) => {
@@ -18,13 +21,13 @@ export const FormSavePlugin: React.FC<Props> = (props) => {
// Pass in fields, and indicate if you'd like to "unflatten" field data.
// The result below will reflect the data stored in the form at the given time
const formData = reduceFieldsToValues(fields, true)
const newFormData = reduceFieldsToValues(fields, true)
useEffect(() => {
if (onChange) {
onChange({ fields, formData })
onChange({ fullFieldsWithValues: fields, newFormData })
}
}, [formData, onChange, fields])
}, [newFormData, onChange, fields])
return null
}

View File

@@ -1,5 +1,4 @@
'use client'
import { type ElementFormatType } from 'lexical'
import { Form, buildInitialState, useFormSubmitted } from 'payload/components/forms'
import React, { useEffect, useMemo } from 'react'
@@ -20,37 +19,45 @@ import { useTranslation } from 'react-i18next'
import type { BlocksFeatureProps } from '..'
import { useEditorConfigContext } from '../../../lexical/config/EditorConfigProvider'
import { transformInputFormSchema } from '../utils/transformInputFormSchema'
import { BlockContent } from './BlockContent'
import './index.scss'
type Props = {
blockFieldWrapperName: string
children?: React.ReactNode
className?: string
fields: BlockFields
format?: ElementFormatType
/**
* This formData already comes wrapped in blockFieldWrapperName
*/
formData: BlockFields
nodeKey?: string
}
export const BlockComponent: React.FC<Props> = (props) => {
const { children, className, fields, format, nodeKey } = props
const { blockFieldWrapperName, formData, nodeKey } = props
const payloadConfig = useConfig()
const submitted = useFormSubmitted()
const { editorConfig, field } = useEditorConfigContext()
const { editorConfig, field: parentLexicalRichTextField } = useEditorConfigContext()
const block = (
editorConfig?.resolvedFeatureMap?.get('blocks')?.props as BlocksFeatureProps
)?.blocks?.find((block) => block.slug === fields?.data?.blockType)
)?.blocks?.find((block) => block.slug === formData?.blockType)
const unsanitizedFormSchema = block?.fields || []
// Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here
const validRelationships = payloadConfig.collections.map((c) => c.slug) || []
block.fields = sanitizeFields({
const formSchema = transformInputFormSchema(
sanitizeFields({
config: payloadConfig,
fields: block.fields,
fields: unsanitizedFormSchema,
validRelationships,
})
}),
blockFieldWrapperName,
)
const initialStateRef = React.useRef<Data>(buildInitialState(fields.data || {})) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
const initialStateRef = React.useRef<Data>(null) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
const config = useConfig()
const { t } = useTranslation('general')
@@ -58,52 +65,58 @@ export const BlockComponent: React.FC<Props> = (props) => {
const { getDocPreferences } = useDocumentInfo()
// initialState State
const [initialState, setInitialState] = React.useState<Data>(null)
useEffect(() => {
async function buildInitialState() {
async function createInitialState() {
const preferences = await getDocPreferences()
const stateFromSchema = await buildStateFromSchema({
config,
data: fields.data,
fieldSchema: block.fields,
data: JSON.parse(JSON.stringify(formData)),
fieldSchema: formSchema as any,
locale,
operation: 'update',
operation: 'create',
preferences,
t,
})
if (!initialStateRef.current) {
initialStateRef.current = buildInitialState(JSON.parse(JSON.stringify(formData)))
}
// We have to merge the output of buildInitialState (above this useEffect) with the output of buildStateFromSchema.
// That's because the output of buildInitialState provides important properties necessary for THIS block,
// like blockName, blockType and id, while buildStateFromSchema provides the correct output of this block's data,
// e.g. if this block has a sub-block (like the `rows` property)
setInitialState({
...initialStateRef?.current,
const consolidatedInitialState = {
...initialStateRef.current,
...stateFromSchema,
})
}
void buildInitialState()
}, [setInitialState, config, block, locale, getDocPreferences, t]) // do not add fields here, it causes an endless loop
setInitialState(consolidatedInitialState)
}
void createInitialState()
}, [setInitialState, config, locale, getDocPreferences, t]) // do not add formData or formSchema here, it causes an endless loop
// Memoized Form JSX
const formContent = useMemo(() => {
return (
block &&
initialState && (
<Form fields={block.fields} initialState={initialState} submitted={submitted}>
<Form fields={formSchema} initialState={initialState} submitted={submitted}>
<BlockContent
baseClass={baseClass}
block={block}
field={field}
fields={fields}
field={parentLexicalRichTextField}
formData={formData}
formSchema={formSchema}
nodeKey={nodeKey}
/>
</Form>
)
)
}, [block, field, nodeKey, submitted, initialState])
}, [block, parentLexicalRichTextField, nodeKey, submitted, initialState])
return <div className={baseClass}>{formContent}</div>
}

View File

@@ -37,10 +37,9 @@ const insertBlock = ({
}) => {
if (!replaceNodeKey) {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
data: {
id: null,
blockName: '',
blockType: blockType,
},
})
} else {
editor.update(() => {
@@ -48,10 +47,9 @@ const insertBlock = ({
if (node) {
node.replace(
$createBlockNode({
data: {
id: null,
blockName: '',
blockType: blockType,
},
}),
)
}

View File

@@ -20,22 +20,15 @@ export type BlocksFeatureProps = {
export const BlocksFeature = (props?: BlocksFeatureProps): FeatureProvider => {
// Sanitization taken from payload/src/fields/config/sanitize.ts
if (props?.blocks?.length) {
props.blocks = props.blocks.map((block) => ({
props.blocks = props.blocks.map((block) => {
return {
...block,
fields: block.fields.concat(baseBlockFields),
}))
props.blocks = props.blocks.map((block) => {
const unsanitizedBlock = { ...block }
unsanitizedBlock.labels = !unsanitizedBlock.labels
? formatLabels(unsanitizedBlock.slug)
: unsanitizedBlock.labels
labels: !block.labels ? formatLabels(block.slug) : block.labels,
}
})
// unsanitizedBlock.fields are sanitized in the React component and not here.
// That's because we do not have access to the payload config here.
return unsanitizedBlock
})
}
return {
feature: () => {
@@ -59,13 +52,6 @@ export const BlocksFeature = (props?: BlocksFeatureProps): FeatureProvider => {
options: [
{
options: [
/*new SlashMenuOption('Block', {
Icon: BlockIcon,
keywords: ['block', 'blocks'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_WITH_DRAWER_COMMAND, null)
},
}),*/
...props?.blocks?.map((block) => {
return new SlashMenuOption(block.slug, {
Icon: BlockIcon,
@@ -75,10 +61,9 @@ export const BlocksFeature = (props?: BlocksFeatureProps): FeatureProvider => {
keywords: ['block', 'blocks', block.slug],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
data: {
id: null,
blockName: '',
blockType: block.slug,
},
})
},
})

View File

@@ -15,15 +15,14 @@ import ObjectID from 'bson-objectid'
import React from 'react'
import { BlockComponent } from '../component'
import { transformInputFormData } from '../utils/transformInputFormData'
export type BlockFields = {
/** Block data */
data: {
/** Block form data */
[key: string]: any
blockName: string
blockType: string
id?: string
}
id: string
}
export type SerializedBlockNode = Spread<
@@ -66,6 +65,16 @@ export class BlockNode extends DecoratorBlockNode {
}
static importJSON(serializedNode: SerializedBlockNode): BlockNode {
if (serializedNode.version === 1) {
// Convert (version 1 had the fields wrapped in another, unnecessary data property)
serializedNode = {
...serializedNode,
fields: {
...(serializedNode as any).data.fields,
},
version: 2,
}
}
const node = $createBlockNode(serializedNode.fields)
node.setFormat(serializedNode.format)
return node
@@ -75,11 +84,13 @@ export class BlockNode extends DecoratorBlockNode {
return false
}
decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element {
const blockFieldWrapperName = this.getFields().blockType + '-' + this.getFields().id
const transformedFormData = transformInputFormData(this.getFields(), blockFieldWrapperName)
return (
<BlockComponent
className={config.theme.block ?? 'LexicalEditorTheme__block'}
fields={this.__fields}
format={this.__format}
blockFieldWrapperName={blockFieldWrapperName}
formData={transformedFormData}
nodeKey={this.getKey()}
/>
)
@@ -98,24 +109,37 @@ export class BlockNode extends DecoratorBlockNode {
...super.exportJSON(),
fields: this.getFields(),
type: this.getType(),
version: 1,
version: 2,
}
}
getFields(): BlockFields {
return this.getLatest().__fields
}
getId(): string {
return this.__id
}
getTextContent(): string {
return `Block Field`
}
setFields(fields: BlockFields): void {
let fieldsCopy = JSON.parse(JSON.stringify(fields)) as BlockFields
// Possibly transform fields
const blockFieldWrapperName = fieldsCopy.blockType + '-' + fieldsCopy.id
if (fieldsCopy[blockFieldWrapperName]) {
fieldsCopy = {
id: fieldsCopy.id,
blockName: fieldsCopy.blockName,
blockType: fieldsCopy.blockType,
...fieldsCopy[blockFieldWrapperName],
}
delete fieldsCopy[blockFieldWrapperName]
}
const writable = this.getWritable()
writable.__fields = fields
writable.__fields = fieldsCopy
}
}
@@ -123,10 +147,7 @@ export function $createBlockNode(fields: Exclude<BlockFields, 'id'>): BlockNode
return new BlockNode({
fields: {
...fields,
data: {
...fields.data,
id: fields?.data?.id || new ObjectID().toHexString(),
},
id: fields?.id || new ObjectID().toHexString(),
},
})
}

View File

@@ -9,7 +9,7 @@ import type { BlockFields } from '../nodes/BlocksNode'
import { BlocksDrawerComponent } from '../drawer'
import { $createBlockNode, BlockNode } from '../nodes/BlocksNode'
export type InsertBlockPayload = BlockFields
export type InsertBlockPayload = Exclude<BlockFields, 'id'>
export const INSERT_BLOCK_COMMAND: LexicalCommand<InsertBlockPayload> =
createCommand('INSERT_BLOCK_COMMAND')

View File

@@ -21,7 +21,7 @@ export const blockPopulationPromiseHOC = (
showHiddenFields,
}) => {
const blocks: Block[] = props.blocks
const blockFieldData = node.fields.data
const blockFieldData = node.fields
const promises: Promise<void>[] = []

View File

@@ -0,0 +1,25 @@
/**
* Wraps the input formData in a blockFieldWrapperName, so that it can be read by the RenderFields component
* which requires it to be wrapped in a group field
*/
export function transformInputFormData(data: any, blockFieldWrapperName: string) {
const dataCopy = JSON.parse(JSON.stringify(data))
const fieldDataWithoutBlockFields = { ...dataCopy }
delete fieldDataWithoutBlockFields['id']
delete fieldDataWithoutBlockFields['blockName']
delete fieldDataWithoutBlockFields['blockType']
// Wrap all fields inside blockFieldWrapperName.
// This is necessary, because blockFieldWrapperName is set as the 'base' path for all fields in the block (in the RenderFields component).
// Thus, in order for the data to be read, it has to be wrapped in this blockFieldWrapperName, as it's expected to be there.
// Why are we doing this? Because that way, all rendered fields of the blocks have different paths and names, and thus don't conflict with each other.
// They have different paths and names, because they are wrapped in the blockFieldWrapperName, which has a name that is unique for each block.
return {
id: dataCopy.id,
[blockFieldWrapperName]: fieldDataWithoutBlockFields,
blockName: dataCopy.blockName,
blockType: dataCopy.blockType,
}
}

View File

@@ -0,0 +1,31 @@
import type { Field } from 'payload/types'
export function transformInputFormSchema(formSchema: any, blockFieldWrapperName: string): Field[] {
const formSchemaCopy = [...formSchema]
// First, check if it needs wrapping
const hasBlockFieldWrapper = formSchemaCopy.some(
(field) => 'name' in field && field.name === blockFieldWrapperName,
)
if (hasBlockFieldWrapper) {
return formSchemaCopy
}
// Add a group in the field schema, which represents all values saved in the blockFieldWrapperName
return [
...formSchemaCopy.filter(
(field) => 'name' in field && ['blockName', 'blockType', 'id'].includes(field.name),
),
{
name: blockFieldWrapperName,
admin: {
hideGutter: true,
},
fields: formSchemaCopy.filter(
(field) => !('name' in field) || !['blockName', 'blockType', 'id'].includes(field.name),
),
label: '',
type: 'group',
},
]
}

View File

@@ -15,7 +15,7 @@ export const blockValidationHOC = (
payloadConfig,
validation,
}) => {
const blockFieldData = node.fields.data
const blockFieldData = node.fields
const blocks: Block[] = props.blocks
// Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here
@@ -38,7 +38,7 @@ export const blockValidationHOC = (
for (const field of block.fields) {
if ('validate' in field && typeof field.validate === 'function' && field.validate) {
const fieldValue = 'name' in field ? node.fields.data[field.name] : null
const fieldValue = 'name' in field ? node.fields[field.name] : null
const validationResult = await field.validate(fieldValue, {
...field,
id: validation.options.id,

View File

@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { initPageConsoleErrorCatch } from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
@@ -17,6 +18,7 @@ describe('Admin Panel', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
test('example test', async () => {

View File

@@ -6,7 +6,7 @@ import type { ReadOnlyCollection, RestrictedVersion } from './payload-types'
import payload from '../../packages/payload/src'
import wait from '../../packages/payload/src/utilities/wait'
import { exactText, openDocControls, openNav } from '../helpers'
import { exactText, initPageConsoleErrorCatch, openDocControls, openNav } from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import {
@@ -47,6 +47,7 @@ describe('access control', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
test('field without read access should not show', async () => {

View File

@@ -12,6 +12,7 @@ import {
checkBreadcrumb,
checkPageTitle,
exactText,
initPageConsoleErrorCatch,
openDocControls,
openNav,
saveDocAndAssert,
@@ -61,6 +62,7 @@ describe('admin', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
beforeEach(async () => {
await clearAndSeedEverything(payload)

View File

@@ -2,7 +2,7 @@ import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { login, saveDocAndAssert } from '../helpers'
import { initPageConsoleErrorCatch, login, saveDocAndAssert } from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import { slug } from './shared'
@@ -27,6 +27,7 @@ describe('auth', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
await login({
page,

View File

@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { initPageConsoleErrorCatch } from '../helpers'
import { initPayloadE2E } from '../helpers/configHelpers'
const { beforeAll, describe } = test
@@ -14,6 +15,7 @@ describe('field error states', () => {
;({ serverURL } = await initPayloadE2E(__dirname))
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
test('Remove row should remove error states from parent fields', async () => {

View File

@@ -13,7 +13,7 @@ import type {
import payload from '../../packages/payload/src'
import { mapAsync } from '../../packages/payload/src/utilities/mapAsync'
import wait from '../../packages/payload/src/utilities/wait'
import { openDocControls, saveDocAndAssert } from '../helpers'
import { initPageConsoleErrorCatch, openDocControls, saveDocAndAssert } from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import {
@@ -47,6 +47,8 @@ describe('fields - relationship', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
beforeEach(async () => {
@@ -445,7 +447,7 @@ describe('fields - relationship', () => {
await expect(relationship).toContainText('Untitled - ID: ')
})
test('should show useAsTitle on relation in list view', async () => {
test('x in list view', async () => {
await page.goto(url.list)
await wait(110)
const relationship = page.locator('.row-1 .cell-relationshipWithTitle')

View File

@@ -13,6 +13,30 @@ export const TextBlock: Block = {
slug: 'text',
}
export const RadioButtonsBlock: Block = {
fields: [
{
name: 'radioButtons',
type: 'radio',
options: [
{
label: 'Option 1',
value: 'option1',
},
{
label: 'Option 2',
value: 'option2',
},
{
label: 'Option 3',
value: 'option3',
},
],
},
],
slug: 'radioButtons',
}
export const RichTextBlock: Block = {
fields: [
{

View File

@@ -76,22 +76,19 @@ export function generateLexicalRichText() {
{
format: '',
type: 'block',
version: 1,
version: 2,
fields: {
data: {
id: '65298b13db4ef8c744a7faaa',
rel: '{{UPLOAD_DOC_ID}}',
blockName: 'Block Node, with Relationship Field',
blockType: 'relationshipBlock',
},
},
},
{
format: '',
type: 'block',
version: 1,
version: 2,
fields: {
data: {
id: '65298b1ddb4ef8c744a7faab',
richText: {
root: {
@@ -135,16 +132,13 @@ export function generateLexicalRichText() {
blockType: 'richText',
},
},
},
{
format: '',
type: 'block',
version: 1,
version: 2,
fields: {
data: {
id: '65298b2bdb4ef8c744a7faac',
blockName:
'Block Node, with Blocks Field, With RichText Field, With Relationship Node',
blockName: 'Block Node, with Blocks Field, With RichText Field, With Relationship Node',
blockType: 'subBlock',
subBlocks: [
{
@@ -192,20 +186,17 @@ export function generateLexicalRichText() {
],
},
},
},
{
format: '',
type: 'block',
version: 1,
version: 2,
fields: {
data: {
id: '65298b49db4ef8c744a7faae',
upload: '{{UPLOAD_DOC_ID}}',
blockName: 'Block Node, With Upload Field',
blockType: 'uploadAndRichText',
},
},
},
{
children: [],
direction: null,
@@ -214,6 +205,28 @@ export function generateLexicalRichText() {
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '65532e49fe515eb112e605a3',
blockName: 'Radio Buttons 1',
blockType: 'radioButtons',
radioButtons: 'option1',
},
},
{
format: '',
type: 'block',
version: 2,
fields: {
id: '65532e50fe515eb112e605a4',
blockName: 'Radio Buttons 2',
blockType: 'radioButtons',
radioButtons: 'option1',
},
},
{
children: [],
direction: null,

View File

@@ -2,16 +2,14 @@ import type { CollectionConfig } from '../../../../packages/payload/src/collecti
import {
BlocksFeature,
HTMLConverterFeature,
LexicalPluginToLexicalFeature,
LinkFeature,
TestRecorderFeature,
TreeviewFeature,
UploadFeature,
lexicalEditor,
} from '../../../../packages/richtext-lexical/src'
import { lexicalFieldsSlug } from '../../slugs'
import {
RadioButtonsBlock,
RelationshipBlock,
RichTextBlock,
SelectFieldBlock,
@@ -51,6 +49,7 @@ export const LexicalFields: CollectionConfig = {
SelectFieldBlock,
RelationshipBlock,
SubBlockBlock,
RadioButtonsBlock,
],
}),
],
@@ -102,6 +101,7 @@ export const LexicalFields: CollectionConfig = {
SelectFieldBlock,
RelationshipBlock,
SubBlockBlock,
RadioButtonsBlock,
],
}),
],

View File

@@ -2,8 +2,6 @@
import React from 'react'
const AfterInputImpl: React.FC = () => {
export const AfterInput: React.FC = () => {
return <label className="after-input">#after-input</label>
}
export const AfterInput = <AfterInputImpl />

View File

@@ -2,8 +2,6 @@
import React from 'react'
const BeforeInputImpl: React.FC = () => {
export const BeforeInput: React.FC = () => {
return <label className="before-input">#before-input</label>
}
export const BeforeInput = <BeforeInputImpl />

View File

@@ -104,8 +104,8 @@ const TextFields: CollectionConfig = {
name: 'beforeAndAfterInput',
admin: {
components: {
AfterInput: [AfterInput],
BeforeInput: [BeforeInput],
afterInput: [AfterInput],
beforeInput: [BeforeInput],
},
},
type: 'text',

View File

@@ -6,7 +6,12 @@ import path from 'path'
import payload from '../../packages/payload/src'
import { mapAsync } from '../../packages/payload/src/utilities/mapAsync'
import wait from '../../packages/payload/src/utilities/wait'
import { exactText, saveDocAndAssert, saveDocHotkeyAndAssert } from '../helpers'
import {
exactText,
initPageConsoleErrorCatch,
saveDocAndAssert,
saveDocHotkeyAndAssert,
} from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import { RESTClient } from '../helpers/rest'
@@ -38,6 +43,7 @@ describe('fields', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
beforeEach(async () => {
await clearAndSeedEverything(payload)
@@ -97,7 +103,7 @@ describe('fields', () => {
await expect(error).toHaveText('#custom-error')
})
test('should render BeforeInput and AfterInput', async () => {
test('should render beforeInput and afterInput', async () => {
await page.goto(url.create)
const input = page.locator('input[id="field-beforeAndAfterInput"]')
@@ -105,13 +111,13 @@ describe('fields', () => {
return el.previousElementSibling
})
const prevSiblingText = await page.evaluate((el) => el.textContent, prevSibling)
await expect(prevSiblingText).toEqual('#before-input')
expect(prevSiblingText).toEqual('#before-input')
const nextSibling = await input.evaluateHandle((el) => {
return el.nextElementSibling
})
const nextSiblingText = await page.evaluate((el) => el.textContent, nextSibling)
await expect(nextSiblingText).toEqual('#after-input')
expect(nextSiblingText).toEqual('#after-input')
})
})

View File

@@ -7,7 +7,7 @@ import type { SerializedBlockNode } from '../../packages/richtext-lexical/src'
import type { LexicalField } from './payload-types'
import payload from '../../packages/payload/src'
import { saveDocAndAssert } from '../helpers'
import { initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import { RESTClient } from '../helpers/rest'
@@ -42,6 +42,8 @@ describe('lexical', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
beforeEach(async () => {
await clearAndSeedEverything(payload)
@@ -224,8 +226,7 @@ describe('lexical', () => {
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const blockNode: SerializedBlockNode = lexicalField.root.children[3] as SerializedBlockNode
const textNodeInBlockNodeRichText =
blockNode.fields.data.richText.root.children[1].children[0]
const textNodeInBlockNodeRichText = blockNode.fields.richText.root.children[1].children[0]
expect(textNodeInBlockNodeRichText.text).toBe(
'Some text below relationship node 1 inserted text',
@@ -298,7 +299,7 @@ describe('lexical', () => {
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const blockNode: SerializedBlockNode = lexicalField.root.children[3] as SerializedBlockNode
const paragraphNodeInBlockNodeRichText = blockNode.fields.data.richText.root.children[1]
const paragraphNodeInBlockNodeRichText = blockNode.fields.richText.root.children[1]
expect(paragraphNodeInBlockNodeRichText.children).toHaveLength(2)
@@ -403,7 +404,7 @@ describe('lexical', () => {
* Check if it was created successfully and
* fill newly created textarea sub-block with text
*/
const newSubBlock = lexicalBlock.locator('#subBlocks-row-1')
const newSubBlock = lexicalBlock.locator('.blocks-field__rows > div').nth(1)
await expect(newSubBlock).toBeVisible()
const newContentTextArea = newSubBlock.locator('textarea').first()
@@ -437,7 +438,7 @@ describe('lexical', () => {
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const blockNode: SerializedBlockNode = lexicalField.root.children[4] as SerializedBlockNode
const subBlocks = blockNode.fields.data.subBlocks
const subBlocks = blockNode.fields.subBlocks
expect(subBlocks).toHaveLength(2)
@@ -446,6 +447,69 @@ describe('lexical', () => {
expect(createdTextAreaBlock.content).toBe('text123')
})
test('should allow changing values of two different radio button blocks independently', async () => {
// This test ensures that https://github.com/payloadcms/payload/issues/3911 does not happen again
await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').nth(1) // second
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
const radioButtonBlock1 = richTextField.locator('.lexical-block').nth(4)
const radioButtonBlock2 = richTextField.locator('.lexical-block').nth(5)
await radioButtonBlock2.scrollIntoViewIfNeeded()
await expect(radioButtonBlock1).toBeVisible()
await expect(radioButtonBlock2).toBeVisible()
// Click radio button option2 of radioButtonBlock1
await radioButtonBlock1
.locator('.radio-input:has-text("Option 2")')
.first() // This already is an input for some reason
.click()
// Ensure radio button option1 of radioButtonBlock2 (the default option) is still selected
await expect(
radioButtonBlock2.locator('.radio-input:has-text("Option 1")').first(),
).toBeChecked()
// Click radio button option3 of radioButtonBlock2
await radioButtonBlock2
.locator('.radio-input:has-text("Option 3")')
.first() // This already is an input for some reason
.click()
// Ensure previously clicked option2 of radioButtonBlock1 is still selected
await expect(
radioButtonBlock1.locator('.radio-input:has-text("Option 2")').first(),
).toBeChecked()
/**
* Now save and check the actual data. radio button block 1 should have option2 selected and radio button block 2 should have option3 selected
*/
await saveDocAndAssert(page)
const lexicalDoc: RichTextField = (
await payload.find({
collection: lexicalFieldsSlug,
where: {
title: {
equals: lexicalDocData.title,
},
},
depth: 0,
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalWithBlocks
const radio1: SerializedBlockNode = lexicalField.root.children[7]
const radio2: SerializedBlockNode = lexicalField.root.children[8]
expect(radio1.fields.radioButtons).toBe('option2')
expect(radio2.fields.radioButtons).toBe('option3')
})
test('should not lose focus when writing in nested editor', async () => {
// https://github.com/payloadcms/payload/issues/4108
// Steps:

View File

@@ -304,7 +304,7 @@ describe('Lexical', () => {
/**
* Depth 1 population:
*/
expect(relationshipBlockNode.fields.data.rel).toStrictEqual(createdJPGDocID)
expect(relationshipBlockNode.fields.rel).toStrictEqual(createdJPGDocID)
})
it('should populate relationships in blocks with depth=1', async () => {
@@ -328,7 +328,7 @@ describe('Lexical', () => {
/**
* Depth 1 population:
*/
expect(relationshipBlockNode.fields.data.rel.filename).toStrictEqual('payload.jpg')
expect(relationshipBlockNode.fields.rel.filename).toStrictEqual('payload.jpg')
})
it('should not populate relationship nodes inside of a sub-editor from a blocks node with 0 depth', async () => {
@@ -349,7 +349,7 @@ describe('Lexical', () => {
const subEditorBlockNode: SerializedBlockNode = lexicalField.root
.children[3] as SerializedBlockNode
const subEditor: SerializedEditorState = subEditorBlockNode.fields.data.richText
const subEditor: SerializedEditorState = subEditorBlockNode.fields.richText
const subEditorRelationshipNode: SerializedRelationshipNode = subEditor.root
.children[0] as SerializedRelationshipNode
@@ -380,7 +380,7 @@ describe('Lexical', () => {
const subEditorBlockNode: SerializedBlockNode = lexicalField.root
.children[3] as SerializedBlockNode
const subEditor: SerializedEditorState = subEditorBlockNode.fields.data.richText
const subEditor: SerializedEditorState = subEditorBlockNode.fields.richText
const subEditorRelationshipNode: SerializedRelationshipNode = subEditor.root
.children[0] as SerializedRelationshipNode
@@ -427,7 +427,7 @@ describe('Lexical', () => {
const subEditorBlockNode: SerializedBlockNode = lexicalField.root
.children[3] as SerializedBlockNode
const subEditor: SerializedEditorState = subEditorBlockNode.fields.data.richText
const subEditor: SerializedEditorState = subEditorBlockNode.fields.richText
const subEditorRelationshipNode: SerializedRelationshipNode = subEditor.root
.children[0] as SerializedRelationshipNode

View File

@@ -116,3 +116,20 @@ export const findTableRow = async (page: Page, title: string): Promise<Locator>
expect(row).toBeTruthy()
return row
}
/**
* Throws an error when browser console error messages (with some exceptions) are thrown, thus resulting
* in the e2e test failing.
*
* Useful to prevent the e2e test from passing when, for example, there are react missing key prop errors
* @param page
*/
export function initPageConsoleErrorCatch(page: Page) {
page.on('console', (msg) => {
if (msg.type() === 'error' && !msg.text().includes('the server responded with a status of')) {
// the the server responded with a status of error happens frequently. Will ignore it for now.
// Most importantly, this should catch react errors.
throw new Error(`Browser console error: ${msg.text()}`)
}
})
}

View File

@@ -2,7 +2,7 @@ import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { exactText, saveDocAndAssert } from '../helpers'
import { exactText, initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import { mobileBreakpoint } from './shared'
@@ -43,6 +43,8 @@ describe('Live Preview', () => {
await startLivePreviewDemo({
payload,
})
initPageConsoleErrorCatch(page)
})
test('collection - has tab', async () => {
@@ -162,13 +164,16 @@ describe('Live Preview', () => {
// Check that the breakpoint select is present
const breakpointSelector = page.locator(
'.live-preview-toolbar-controls__breakpoint button.popup-button'
'.live-preview-toolbar-controls__breakpoint button.popup-button',
)
expect(breakpointSelector).toBeTruthy()
// 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()
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
expect(breakpointSelector).toContainText(mobileBreakpoint.label)

View File

@@ -6,7 +6,12 @@ import type { LocalizedPost } from './payload-types'
import payload from '../../packages/payload/src'
import wait from '../../packages/payload/src/utilities/wait'
import { changeLocale, openDocControls, saveDocAndAssert } from '../helpers'
import {
changeLocale,
initPageConsoleErrorCatch,
openDocControls,
saveDocAndAssert,
} from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadTest } from '../helpers/configHelpers'
import { englishTitle, localizedPostsSlug, spanishLocale } from './shared'
@@ -44,6 +49,8 @@ describe('Localization', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
describe('localized text', () => {

View File

@@ -2,7 +2,7 @@ import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { closeNav, openNav } from '../helpers'
import { closeNav, initPageConsoleErrorCatch, openNav } from '../helpers'
import { initPayloadE2E } from '../helpers/configHelpers'
const { beforeAll, describe } = test
@@ -15,6 +15,8 @@ describe('refresh-permissions', () => {
;({ serverURL } = await initPayloadE2E(__dirname))
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
test('should show test global immediately after allowing access', async () => {

View File

@@ -7,7 +7,7 @@ import type { Media } from './payload-types'
import payload from '../../packages/payload/src'
import wait from '../../packages/payload/src/utilities/wait'
import { saveDocAndAssert } from '../helpers'
import { initPageConsoleErrorCatch, saveDocAndAssert } from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import { RESTClient } from '../helpers/rest'
@@ -40,6 +40,8 @@ describe('uploads', () => {
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
const findPNG = await payload.find({
collection: mediaSlug,
depth: 0,

View File

@@ -30,7 +30,13 @@ import { expect, test } from '@playwright/test'
import payload from '../../packages/payload/src'
import wait from '../../packages/payload/src/utilities/wait'
import { globalSlug } from '../admin/slugs'
import { changeLocale, exactText, findTableCell, selectTableRow } from '../helpers'
import {
changeLocale,
exactText,
findTableCell,
initPageConsoleErrorCatch,
selectTableRow,
} from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import { clearAndSeedEverything } from './seed'
@@ -55,6 +61,8 @@ describe('versions', () => {
serverURL = config.serverURL
const context = await browser.newContext()
page = await context.newPage()
initPageConsoleErrorCatch(page)
})
beforeEach(async () => {
@@ -240,6 +248,7 @@ describe('versions', () => {
expect(page.url()).toMatch(/\/versions$/)
})
// TODO: This test is flaky and fails sometimes
test('global - should autosave', async () => {
const url = new AdminUrlUtil(serverURL, autoSaveGlobalSlug)
// fill out global title and wait for autosave