Compare commits

..

28 Commits

Author SHA1 Message Date
Elliot DeNolf
7caa098023 chore(release): db-postgres/0.1.5 2023-10-14 12:25:54 -04:00
James Mikrut
fd54c40400 Merge pull request #3654 from payloadcms/chore/dynamic-drizzle-kit-import
chore: only imports drizzle-kit if it will be used
2023-10-14 12:21:14 -04:00
James
e180131314 chore: only imports drizzle-kit if it will be used 2023-10-14 12:13:13 -04:00
James
5902d4542b Merge branch 'main' of github.com:payloadcms/payload 2023-10-14 11:51:13 -04:00
James
6bc282444e chore: slate compatibility with next-payload 2023-10-14 11:49:38 -04:00
Alessio Gravili
4dc6c09347 feat(richtext-lexical): SlateToLexical migration feature 2023-10-14 13:36:32 +02:00
Elliot DeNolf
03b9ab0054 chore: cleanup scripts 2023-10-13 16:34:37 -04:00
Elliot DeNolf
3c3c93f483 chore(release): richtext-lexical/0.1.8 2023-10-13 16:05:59 -04:00
Alessio Gravili
5dbfb1a335 fix(richtext-lexical): Blocks: working population for crazy amounts of nesting 2023-10-13 21:04:56 +02:00
Alessio Gravili
d411874589 chore(richtext-lexical): Blocks: clean up population 2023-10-13 20:02:18 +02:00
Jacob Fletcher
8358e2f2d2 chore: properly scopes selector in bulk update e2e test (#3640) 2023-10-13 13:51:52 -04:00
Elliot DeNolf
012b8e6f90 chore: remove pnpm from engines, shows warning when not using pnpm 2023-10-13 13:05:25 -04:00
Jacob Fletcher
fcd4c8d830 fix: document sidebar vertical overflow (#3639) 2023-10-13 13:00:02 -04:00
Elliot DeNolf
81ec435363 chore(release): richtext-lexical/0.1.7 2023-10-13 12:49:08 -04:00
Jacob Fletcher
e116fcfbf5 docs: updates references of master to main 2023-10-13 12:44:45 -04:00
Alessio Gravili
c47632dc1d fix(richtext-lexical): Blocks: Nested Blocks having incorrect initial data (e.g. missing rows property) (#3638)
* fix(richtext-lexical): Blocks: Sub-Blocks having incorrect initial data (e.g. missing rows property)

* chore: remove unnecessary comment
2023-10-13 18:39:34 +02:00
Jacob Fletcher
0dab68b336 chore: prevents group fields from overflowing into the sidebar (#3637) 2023-10-13 12:04:39 -04:00
Jacob Fletcher
483f93bfcf chore: cleans up admin e2e tests (#3636) 2023-10-13 12:04:05 -04:00
Jessica Chowdhury
4bd01df411 fix: login form clearing out and field spacing (#3633) 2023-10-13 11:15:07 -04:00
Jessica Chowdhury
c956a85252 fix: sidebar field permissions (#3629)
Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2023-10-13 10:29:22 -04:00
Jacob Fletcher
beed83b231 fix: preview button conditions (#3613) 2023-10-13 10:23:26 -04:00
James Mikrut
3b1bdcbe41 chore: de-duplicates array / block data from form state (#3607)
* chore: consolidates array manipulation tests

---------

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2023-10-13 09:45:00 -04:00
James Mikrut
d3d0971275 Merge pull request #3630 from payloadcms/fix/duplicating-drafts
fix: allows drafts to be duplicated
2023-10-13 08:50:36 -04:00
Jessica Boezwinkle
1a99d66cd0 fix: allows drafts to be duplicated 2023-10-13 11:59:23 +01:00
Elliot DeNolf
52c4a63bf1 chore(release): richtext-slate/1.0.3 2023-10-12 23:38:38 -04:00
Elliot DeNolf
3446d28602 chore(release): richtext-lexical/0.1.6 2023-10-12 23:38:29 -04:00
Elliot DeNolf
2eb18771a1 chore(release): live-preview-react/0.1.3 2023-10-12 23:38:25 -04:00
Elliot DeNolf
f6fd5d6742 chore(release): live-preview/0.1.3 2023-10-12 23:38:20 -04:00
97 changed files with 2070 additions and 653 deletions

View File

@@ -129,7 +129,7 @@ To add a _new_ view to the Admin Panel, simply add another key to the `views` ob
}
```
_For more examples regarding how to customize components, look at the following [examples](https://github.com/payloadcms/payload/tree/master/test/admin/components)._
_For more examples regarding how to customize components, look at the following [examples](https://github.com/payloadcms/payload/tree/main/test/admin/components)._
For help on how to build your own custom view components, see [building a custom view component](#building-a-custom-view-component).
@@ -399,12 +399,12 @@ Your custom view components will be given all the props that a React Router `<Ro
#### Example
You can find examples of custom views in the [Payload source code `/test/admin/components/views` folder](https://github.com/payloadcms/payload/tree/master/test/admin/components/views). There, you'll find two custom views:
You can find examples of custom views in the [Payload source code `/test/admin/components/views` folder](https://github.com/payloadcms/payload/tree/main/test/admin/components/views). There, you'll find two custom views:
1. A custom view that uses the `DefaultTemplate`, which is the built-in Payload template that displays the sidebar and "eyebrow nav"
1. A custom view that uses the `MinimalTemplate` - which is just a centered template used for things like logging in or out
To see how to pass in your custom views to create custom views of your own, take a look at the `admin.components.views` property of the [Payload test admin config](https://github.com/payloadcms/payload/blob/master/test/admin/config.ts).
To see how to pass in your custom views to create custom views of your own, take a look at the `admin.components.views` property of the [Payload test admin config](https://github.com/payloadcms/payload/blob/main/test/admin/config.ts).
### Fields

View File

@@ -20,8 +20,8 @@
"lint-staged": "lint-staged",
"pretest": "pnpm build",
"reinstall": "pnpm clean:unix && pnpm install",
"list:packages": "./scripts/list_published_packages.sh beta",
"script:release:beta": "./scripts/release_beta.sh",
"script:list-packages": "tsx ./scripts/list-packages.ts",
"script:release": "tsx ./scripts/release.ts",
"test": "pnpm test:int && pnpm test:components && pnpm test:e2e",
"test:components": "cross-env jest --config=jest.components.config.js",
"test:e2e": "npx playwright install --with-deps && ts-node -T ./test/runE2E.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "0.1.4",
"version": "0.1.5",
"description": "The officially supported Postgres database adapter for Payload",
"repository": "https://github.com/payloadcms/payload",
"license": "MIT",

View File

@@ -1,6 +1,5 @@
import type { Connect } from 'payload/database'
import { pushSchema } from 'drizzle-kit/utils'
import { eq, sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/node-postgres'
import { numeric, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'
@@ -40,6 +39,8 @@ export const connect: Connect = async function connect(this: PostgresAdapter, pa
)
return
const { pushSchema } = require('drizzle-kit/utils')
// This will prompt if clarifications are needed for Drizzle to push new schema
const { apply, hasDataLoss, statementsToExecute, warnings } = await pushSchema(
this.schema,

View File

@@ -2,7 +2,6 @@
import type { DrizzleSnapshotJSON } from 'drizzle-kit/utils'
import type { CreateMigration } from 'payload/database'
import { generateDrizzleJson, generateMigration } from 'drizzle-kit/utils'
import fs from 'fs'
import prompts from 'prompts'
@@ -61,6 +60,8 @@ export const createMigration: CreateMigration = async function createMigration(
fs.mkdirSync(dir)
}
const { generateDrizzleJson, generateMigration } = require('drizzle-kit/utils')
const [yyymmdd, hhmmss] = new Date().toISOString().split('T')
const formattedDate = yyymmdd.replace(/\D/g, '')
const formattedTime = hhmmss.split('.')[0].replace(/\D/g, '')

View File

@@ -2,9 +2,7 @@
import type { Payload } from 'payload'
import type { Migration } from 'payload/database'
import { generateDrizzleJson } from 'drizzle-kit/utils'
import { readMigrationFiles } from 'payload/database'
import { DatabaseError } from 'pg'
import prompts from 'prompts'
import type { PostgresAdapter } from './types'
@@ -78,6 +76,8 @@ export async function migrate(this: PostgresAdapter): Promise<void> {
}
async function runMigrationFile(payload: Payload, migration: Migration, batch: number) {
const { generateDrizzleJson } = require('drizzle-kit/utils')
const start = Date.now()
payload.logger.info({ msg: `Migrating: ${migration.name}` })

View File

@@ -6,11 +6,12 @@
// drizzle-kit@utils
import { generateDrizzleJson, generateMigration, pushSchema } from 'drizzle-kit/utils'
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
async function generateUsage() {
const { generateDrizzleJson, generateMigration } = require('drizzle-kit/utils')
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
const schema = await import('./data/users')
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
@@ -25,6 +26,8 @@ async function generateUsage() {
}
async function pushUsage() {
const { pushSchema } = require('drizzle-kit/utils')
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
const schemaAfter = await import('./data/users-after')

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "0.1.2",
"version": "0.1.3",
"description": "The official live preview React SDK for Payload",
"repository": "https://github.com/payloadcms/payload",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "0.1.2",
"version": "0.1.3",
"description": "The official live preview JavaScript SDK for Payload",
"repository": "https://github.com/payloadcms/payload",
"license": "MIT",

View File

@@ -208,8 +208,7 @@
"webpack": "^5.78.0"
},
"engines": {
"node": ">=14",
"pnpm": ">=8"
"node": ">=14"
},
"files": [
"bin.js",

View File

@@ -55,24 +55,6 @@ export const DocumentControls: React.FC<{
const { i18n, t } = useTranslation('general')
let showPreviewButton = false
if (collection) {
showPreviewButton =
isEditing &&
collection?.admin?.preview &&
collection?.versions?.drafts &&
!collection?.versions?.drafts?.autosave
}
if (global) {
showPreviewButton =
isEditing &&
global?.admin?.preview &&
global?.versions?.drafts &&
!global?.versions?.drafts?.autosave
}
const showDotMenu = Boolean(collection && id && !disableActions)
return (
@@ -165,7 +147,7 @@ export const DocumentControls: React.FC<{
</div>
<div className={`${baseClass}__controls-wrapper`}>
<div className={`${baseClass}__controls`}>
{showPreviewButton && (
{(collection?.admin?.preview || global?.admin?.preview) && (
<PreviewButton
CustomComponent={
collection?.admin?.components?.edit?.PreviewButton ||

View File

@@ -51,6 +51,7 @@ const Duplicate: React.FC<Props> = ({ id, collection, slug }) => {
},
params: {
depth: 0,
draft: true,
locale,
},
})

View File

@@ -61,6 +61,7 @@ export const addFieldStatePromise = async ({
user,
value: data?.[field.name],
})
if (data?.[field.name]) {
data[field.name] = valueWithDefault
}
@@ -145,8 +146,8 @@ export const addFieldStatePromise = async ({
fieldState.value = null
fieldState.initialValue = null
} else {
fieldState.value = arrayValue
fieldState.initialValue = arrayValue
fieldState.value = arrayValue.length
fieldState.initialValue = arrayValue.length
if (arrayValue.length > 0) {
fieldState.disableFormData = true
@@ -236,8 +237,8 @@ export const addFieldStatePromise = async ({
fieldState.value = null
fieldState.initialValue = null
} else {
fieldState.value = blocksValue
fieldState.initialValue = blocksValue
fieldState.value = blocksValue.length
fieldState.initialValue = blocksValue.length
if (blocksValue.length > 0) {
fieldState.disableFormData = true

View File

@@ -8,6 +8,9 @@ import getSiblingData from './getSiblingData'
import reduceFieldsToValues from './reduceFieldsToValues'
import { flattenRows, separateRows } from './rows'
/**
* Reducer which modifies the form field state (all the current data of the fields in the form). When called using dispatch, it will return a new state object.
*/
export function fieldReducer(state: Fields, action: FieldAction): Fields {
switch (action.type) {
case 'REPLACE_STATE': {
@@ -123,7 +126,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
...state[path],
disableFormData: rows.length > 0,
rows: rowsMetadata,
value: rows,
value: rows.length,
},
...flattenRows(path, rows),
}
@@ -161,10 +164,6 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
const { remainingFields, rows: siblingRows } = separateRows(path, state)
siblingRows.splice(rowIndex, 0, subFieldState)
// add new row to array _value_
const currentValue = (Array.isArray(state[path]?.value) ? state[path]?.value : []) as Fields[]
const newValue = currentValue.splice(rowIndex, 0, reduceFieldsToValues(subFieldState, true))
const newState: Fields = {
...remainingFields,
...flattenRows(path, siblingRows),
@@ -172,7 +171,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
...state[path],
disableFormData: true,
rows: rowsMetadata,
value: newValue,
value: siblingRows.length,
},
}
@@ -203,10 +202,6 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
// replace form _field state_
siblingRows[rowIndex] = subFieldState
// replace array _value_
const newValue = Array.isArray(state[path]?.value) ? state[path]?.value : []
newValue[rowIndex] = reduceFieldsToValues(subFieldState, true)
const newState: Fields = {
...remainingFields,
...flattenRows(path, siblingRows),
@@ -214,7 +209,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
...state[path],
disableFormData: true,
rows: rowsMetadata,
value: newValue,
value: siblingRows.length,
},
}
@@ -245,7 +240,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
...state[path],
disableFormData: true,
rows: rowsMetadata,
value: rows,
value: rows.length,
},
...flattenRows(path, rows),
}

View File

@@ -50,12 +50,15 @@ import reduceFieldsToValues from './reduceFieldsToValues'
const baseClass = 'form'
const Form: React.FC<Props> = (props) => {
const { id, collection, getDocPreferences, global } = useDocumentInfo()
const {
action,
children,
className,
disableSuccessStatus,
disabled,
fields: fieldsFromProps = collection?.fields || global?.fields,
handleResponse,
initialData, // values only, paths are required as key - form should build initial state as convenience
initialState, // fully formed initial field state
@@ -71,7 +74,6 @@ const Form: React.FC<Props> = (props) => {
const { code: locale } = useLocale()
const { i18n, t } = useTranslation('general')
const { refreshCookie, user } = useAuth()
const { id, collection, getDocPreferences, global } = useDocumentInfo()
const operation = useOperation()
const config = useConfig()
@@ -90,6 +92,10 @@ const Form: React.FC<Props> = (props) => {
if (initialState) initialFieldState = initialState
const fieldsReducer = useReducer(fieldReducer, {}, () => initialFieldState)
/**
* `fields` is the current, up-to-date state/data of all fields in the form. It can be modified by using dispatchFields,
* which calls the fieldReducer, which then updates the state.
*/
const [fields, dispatchFields] = fieldsReducer
contextRef.current.fields = fields
@@ -167,7 +173,13 @@ const Form: React.FC<Props> = (props) => {
let validationResult: boolean | string = true
if (typeof field.validate === 'function') {
validationResult = await field.validate(field.value, {
let valueToValidate = field.value
if (field?.rows && Array.isArray(field.rows)) {
valueToValidate = contextRef.current.getDataByPath(path)
}
validationResult = await field.validate(valueToValidate, {
id,
config,
data,
@@ -434,7 +446,7 @@ const Form: React.FC<Props> = (props) => {
const getRowSchemaByPath = React.useCallback(
({ blockType, path }: { blockType?: string; path: string }) => {
const rowConfig = traverseRowConfigs({
fieldConfig: collection?.fields || global?.fields,
fieldConfig: fieldsFromProps,
path,
})
const rowFieldConfigs = buildFieldSchemaMap(rowConfig)
@@ -442,10 +454,11 @@ const Form: React.FC<Props> = (props) => {
const fieldKey = pathSegments.at(-1)
return rowFieldConfigs.get(blockType ? `${fieldKey}.${blockType}` : fieldKey)
},
[traverseRowConfigs, collection?.fields, global?.fields],
[traverseRowConfigs, fieldsFromProps],
)
// Array/Block row manipulation
// Array/Block row manipulation. This is called when, for example, you add a new block to a blocks field.
// The block data is saved in the rows property of the state, which is modified updated here.
const addFieldRow: Context['addFieldRow'] = useCallback(
async ({ data, path, rowIndex }) => {
const preferences = await getDocPreferences()

View File

@@ -2,7 +2,12 @@ import type React from 'react'
import type { Dispatch } from 'react'
import type { User } from '../../../../auth/types'
import type { Condition, Field as FieldConfig, Validate } from '../../../../fields/config/types'
import type {
Condition,
Field,
Field as FieldConfig,
Validate,
} from '../../../../fields/config/types'
export type Row = {
blockType?: string
@@ -41,6 +46,12 @@ export type Props = {
className?: string
disableSuccessStatus?: boolean
disabled?: boolean
/**
* By default, the form will get the field schema (not data) from the current document. If you pass this in, you can override that behavior.
* This is very useful for sub-forms, where the form's field schema is not necessarily the field schema of the current document (e.g. for the Blocks
* feature of the Lexical Rich Text field)
*/
fields?: Field[]
handleResponse?: (res: Response) => void
initialData?: Data
initialState?: Fields

View File

@@ -17,10 +17,21 @@ const intersectionObserverOptions = {
rootMargin: '1000px',
}
// If you send `fields` through, it will render those fields explicitly
// Otherwise, it will reduce your fields using the other provided props
// This is so that we can conditionally render fields before reducing them, if desired
// See the sidebar in '../collections/Edit/Default/index.tsx' for an example
/**
* If you send `fields` through, it will render those fields explicitly
* Otherwise, it will reduce your fields using the other provided props
* This is so that we can conditionally render fields before reducing them, if desired
* See the sidebar in '../collections/Edit/Default/index.tsx' for an example
*
* The state/data for the fields it renders is not managed by this component. Instead, every component it renders has
* their own handling of their own value, usually through the useField hook. This hook will get the field's value
* from the Form the field is in, using the field's path.
*
* Thus, if you would like to set the value of a field you render here, you must do so in the Form that contains the field, or in the
* Field component itself.
*
* All this component does is render the field's Field Components, and pass them the props they need to function.
**/
const RenderFields: React.FC<Props> = (props) => {
const { className, fieldTypes, forceRender, margins } = props

View File

@@ -6,21 +6,23 @@ import type { ReducedField } from './filterFields'
export type Props = {
className?: string
fieldTypes: FieldTypes
margins?: 'small' | false
forceRender?: boolean
margins?: 'small' | false
permissions?:
| {
[field: string]: FieldPermissions
}
| FieldPermissions
readOnly?: boolean
} & (
| {
// Fields to be filtered by the component
fieldSchema: FieldWithPath[]
filter?: (field: Field) => boolean
indexPath?: string
permissions?:
| {
[field: string]: FieldPermissions
}
| FieldPermissions
readOnly?: boolean
}
| {
// Pre-filtered fields to be simply rendered
fields: ReducedField[]
}
)

View File

@@ -91,7 +91,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
showError,
valid,
value,
} = useField<[]>({
} = useField<number>({
condition,
hasRows: true,
path,
@@ -123,8 +123,8 @@ const ArrayFieldType: React.FC<Props> = (props) => {
)
const removeRow = useCallback(
async (rowIndex: number) => {
await removeFieldRow({ path, rowIndex })
(rowIndex: number) => {
removeFieldRow({ path, rowIndex })
setModified(true)
},
[removeFieldRow, path, setModified],
@@ -278,7 +278,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
icon="plus"
iconPosition="left"
iconStyle="with-border"
onClick={() => addRow(value?.length || 0)}
onClick={() => addRow(value || 0)}
>
{t('addLabel', { label: getTranslation(labels.singular, i18n) })}
</Button>

View File

@@ -90,7 +90,7 @@ const BlocksField: React.FC<Props> = (props) => {
showError,
valid,
value,
} = useField<[]>({
} = useField<number>({
condition,
hasRows: true,
path,
@@ -128,8 +128,8 @@ const BlocksField: React.FC<Props> = (props) => {
)
const removeRow = useCallback(
async (rowIndex: number) => {
await removeFieldRow({ path, rowIndex })
(rowIndex: number) => {
removeFieldRow({ path, rowIndex })
setModified(true)
},
[path, removeFieldRow, setModified],
@@ -297,7 +297,7 @@ const BlocksField: React.FC<Props> = (props) => {
</DrawerToggler>
<BlocksDrawer
addRow={addRow}
addRowIndex={value?.length || 0}
addRowIndex={value || 0}
blocks={blocks}
drawerSlug={drawerSlug}
labels={labels}

View File

@@ -29,7 +29,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
const dispatchField = useFormFields(([_, dispatch]) => dispatch)
const config = useConfig()
const { getData, getSiblingData, setModified } = useForm()
const { getData, getDataByPath, getSiblingData, setModified } = useForm()
const value = field?.value as T
const initialValue = field?.initialValue as T
@@ -116,8 +116,14 @@ const useField = <T,>(options: Options): FieldType<T> => {
user,
}
let valueToValidate = value
if (field?.rows && Array.isArray(field.rows)) {
valueToValidate = getDataByPath(path)
}
const validationResult =
typeof validate === 'function' ? await validate(value, validateOptions) : true
typeof validate === 'function' ? await validate(valueToValidate, validateOptions) : true
if (typeof validationResult === 'string') {
action.errorMessage = validationResult
@@ -132,7 +138,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
}
}
validateField()
void validateField()
},
150,
[
@@ -142,6 +148,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
dispatchField,
getData,
getSiblingData,
getDataByPath,
id,
operation,
path,

View File

@@ -27,7 +27,8 @@
}
&__fields {
& > .tabs-field {
& > .tabs-field,
& > .group-field {
margin-right: calc(var(--base) * -2);
}
}
@@ -51,7 +52,7 @@
position: sticky;
top: var(--doc-controls-height);
width: 33.33%;
height: 100%;
height: calc(100vh - var(--doc-controls-height));
}
&__sidebar {
@@ -110,7 +111,8 @@
}
&__fields {
& > .tabs-field {
& > .tabs-field,
& > .group-field {
margin-right: calc(var(--gutter-h) * -1);
}
}

View File

@@ -90,9 +90,8 @@ export const DefaultGlobalEdit: React.FC<GlobalEditViewProps> = (props) => {
<div className={`${baseClass}__sidebar-sticky-wrap`}>
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields
fieldSchema={fields}
fieldTypes={fieldTypes}
filter={(field) => field.admin.position === 'sidebar'}
fields={sidebarFields}
permissions={permissions.fields}
readOnly={!hasSavePermission}
/>

View File

@@ -21,4 +21,11 @@
margin: 0;
}
}
&__inputWrap {
display: flex;
flex-direction: column;
gap: base(1);
margin-bottom: base(0.25);
}
}

View File

@@ -44,6 +44,8 @@ const Login: React.FC = () => {
}
}
const prefillForm = autoLogin && autoLogin.prefillOnly
return (
<React.Fragment>
{user ? (
@@ -75,22 +77,33 @@ const Login: React.FC = () => {
action={`${serverURL}${api}/${userSlug}/login`}
className={`${baseClass}__form`}
disableSuccessStatus
initialData={{
email: autoLogin && autoLogin.prefillOnly ? autoLogin.email : undefined,
password: autoLogin && autoLogin.prefillOnly ? autoLogin.password : undefined,
}}
initialData={
prefillForm
? {
email: autoLogin.email,
password: autoLogin.password,
}
: undefined
}
method="post"
onSuccess={onSuccess}
waitForAutocomplete
>
<FormLoadingOverlayToggle action="loading" name="login-form" />
<Email
admin={{ autoComplete: 'email' }}
label={t('general:email')}
name="email"
required
/>
<Password autoComplete="off" label={t('general:password')} name="password" required />
<div className={`${baseClass}__inputWrap`}>
<Email
admin={{ autoComplete: 'email' }}
label={t('general:email')}
name="email"
required
/>
<Password
autoComplete="off"
label={t('general:password')}
name="password"
required
/>
</div>
<Link to={`${admin}/forgot`}>{t('forgotPasswordQuestion')}</Link>
<FormSubmit>{t('login')}</FormSubmit>
</Form>

View File

@@ -27,7 +27,8 @@
}
&__fields {
& > .tabs-field {
& > .tabs-field,
& > .group-field {
margin-right: calc(var(--base) * -2);
}
}
@@ -55,7 +56,7 @@
position: sticky;
top: var(--doc-controls-height);
width: 33.33%;
height: 100%;
height: calc(100vh - var(--doc-controls-height));
}
&__sidebar {
@@ -106,7 +107,8 @@
}
&__fields {
& > .tabs-field {
& > .tabs-field,
& > .group-field {
margin-right: calc(var(--gutter-h) * -1);
}
}

View File

@@ -115,7 +115,12 @@ export const DefaultCollectionEdit: React.FC<CollectionEditViewProps> = (props)
<div className={`${baseClass}__sidebar`}>
<div className={`${baseClass}__sidebar-sticky-wrap`}>
<div className={`${baseClass}__sidebar-fields`}>
<RenderFields fieldTypes={fieldTypes} fields={sidebarFields} />
<RenderFields
fieldTypes={fieldTypes}
fields={sidebarFields}
permissions={permissions.fields}
readOnly={!hasSavePermission}
/>
</div>
</div>
</div>

View File

@@ -382,7 +382,7 @@ export const relationship: Validate<unknown, unknown, RelationshipField> = async
})
if (invalidRelationships.length > 0) {
return `This field has the following invalid selections: ${invalidRelationships
return `This relationship field has the following invalid relationships: ${invalidRelationships
.map((err, invalid) => {
return `${err} ${JSON.stringify(invalid)}`
})

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "0.1.5",
"version": "0.1.8",
"description": "The officially supported Lexical richtext adapter for Payload",
"repository": "https://github.com/payloadcms/payload",
"license": "MIT",

View File

@@ -16,17 +16,38 @@ export const RichTextCell: React.FC<
const [preview, setPreview] = React.useState('Loading...')
useEffect(() => {
if (data == null) {
let dataToUse = data
if (dataToUse == null) {
setPreview('')
return
}
// Transform data through load hooks
if (editorConfig?.features?.hooks?.load?.length) {
editorConfig.features.hooks.load.forEach((hook) => {
dataToUse = hook({ incomingEditorState: dataToUse })
})
}
// If data is from Slate and not Lexical
if (dataToUse && Array.isArray(dataToUse) && !('root' in dataToUse)) {
setPreview('')
return
}
// If data is from payload-plugin-lexical
if (dataToUse && 'jsonContent' in dataToUse) {
setPreview('')
return
}
// initialize headless editor
const headlessEditor = createHeadlessEditor({
namespace: editorConfig.lexical.namespace,
nodes: getEnabledNodes({ editorConfig }),
theme: editorConfig.lexical.theme,
})
headlessEditor.setEditorState(headlessEditor.parseEditorState(data))
headlessEditor.setEditorState(headlessEditor.parseEditorState(dataToUse))
const textContent =
headlessEditor.getEditorState().read(() => {

View File

@@ -89,9 +89,16 @@ const RichText: React.FC<FieldProps> = (props) => {
fieldProps={props}
initialState={initialValue}
onChange={(editorState, editor, tags) => {
const json = editorState.toJSON()
let serializedEditorState = editorState.toJSON()
setValue(json)
// Transform state through save hooks
if (editorConfig?.features?.hooks?.save?.length) {
editorConfig.features.hooks.save.forEach((hook) => {
serializedEditorState = hook({ incomingEditorState: serializedEditorState })
})
}
setValue(serializedEditorState)
}}
readOnly={readOnly}
setValue={setValue}

View File

@@ -1,3 +1,5 @@
import type { Block } from 'payload/types'
import { sanitizeFields } from 'payload/config'
import type { BlocksFeatureProps } from '.'
@@ -20,40 +22,42 @@ export const blockAfterReadPromiseHOC = (
showHiddenFields,
siblingDoc,
}) => {
const blocks: Block[] = props.blocks
const blockFieldData = node.fields.data
const promises: Promise<void>[] = []
// Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here
const payloadConfig = req.payload.config
const validRelationships = payloadConfig.collections.map((c) => c.slug) || []
props.blocks = props.blocks.map((block) => {
const unsanitizedBlock = { ...block }
unsanitizedBlock.fields = sanitizeFields({
blocks.forEach((block) => {
block.fields = sanitizeFields({
config: payloadConfig,
fields: block.fields,
validRelationships,
})
return unsanitizedBlock
})
if (Array.isArray(props.blocks)) {
props.blocks.forEach((block) => {
if (block?.fields) {
recurseNestedFields({
afterReadPromises,
currentDepth,
data: node.fields.data || {},
depth,
fields: block.fields,
overrideAccess,
promises,
req,
showHiddenFields,
siblingDoc,
})
}
})
// find block used in this node
const block = props.blocks.find((block) => block.slug === blockFieldData.blockType)
if (!block || !block?.fields?.length || !blockFieldData) {
return promises
}
recurseNestedFields({
afterReadPromises,
currentDepth,
data: blockFieldData,
depth,
fields: block.fields,
overrideAccess,
promises,
req,
showHiddenFields,
// The afterReadPromise gets its data from looking for field.name inside of the siblingDoc. Thus, here we cannot pass the whole document's siblingDoc, but only the siblingDoc (sibling fields) of the current field.
siblingDoc: blockFieldData,
})
return promises
}

View File

@@ -24,6 +24,11 @@ type Props = {
nodeKey: string
}
/**
* The actual content of the Block. This should be INSIDE a Form component,
* scoped to the block. All format operations in here are thus scoped to the block's form, and
* not the whole document.
*/
export const BlockContent: React.FC<Props> = (props) => {
const { baseClass, block, field, fields, nodeKey } = props
const { i18n } = useTranslation()

View File

@@ -1,14 +1,20 @@
import { type ElementFormatType } from 'lexical'
import { Form, buildInitialState, useFormSubmitted } from 'payload/components/forms'
import React, { useMemo } from 'react'
import React, { useEffect, useMemo } from 'react'
import { type BlockFields } from '../nodes/BlocksNode'
const baseClass = 'lexical-block'
import type { Data } from 'payload/types'
import { useConfig } from 'payload/components/utilities'
import {
buildStateFromSchema,
useConfig,
useDocumentInfo,
useLocale,
} from 'payload/components/utilities'
import { sanitizeFields } from 'payload/config'
import { useTranslation } from 'react-i18next'
import type { BlocksFeatureProps } from '..'
@@ -43,13 +49,49 @@ export const BlockComponent: React.FC<Props> = (props) => {
validRelationships,
})
const initialDataRef = 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>(buildInitialState(fields.data || {})) // 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')
const { code: locale } = useLocale()
const { getDocPreferences } = useDocumentInfo()
// initialState State
const [initialState, setInitialState] = React.useState<Data>(null)
useEffect(() => {
async function buildInitialState() {
const preferences = await getDocPreferences()
const stateFromSchema = await buildStateFromSchema({
config,
data: fields.data,
fieldSchema: block.fields,
locale,
operation: 'update',
preferences,
t,
})
// 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,
...stateFromSchema,
})
}
void buildInitialState()
}, [setInitialState, config, block, locale, getDocPreferences, t]) // do not add fields here, it causes an endless loop
// Memoized Form JSX
const formContent = useMemo(() => {
return (
block && (
<Form initialState={initialDataRef?.current} submitted={submitted}>
block &&
initialState && (
<Form fields={block.fields} initialState={initialState} submitted={submitted}>
<BlockContent
baseClass={baseClass}
block={block}
@@ -60,7 +102,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
</Form>
)
)
}, [block, field, nodeKey, submitted])
}, [block, field, nodeKey, submitted, initialState])
return <div className={baseClass}>{formContent}</div>
}

View File

@@ -15,12 +15,12 @@ export const blockValidationHOC = (
payloadConfig,
validation,
}) => {
const blockFieldValues = node.fields.data
const blockFieldData = node.fields.data
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
const validRelationships = payloadConfig.collections.map((c) => c.slug) || []
blocks.forEach((block) => {
const validRelationships = payloadConfig.collections.map((c) => c.slug) || []
block.fields = sanitizeFields({
config: payloadConfig,
fields: block.fields,
@@ -29,7 +29,7 @@ export const blockValidationHOC = (
})
// find block
const block = props.blocks.find((block) => block.slug === blockFieldValues.blockType)
const block = props.blocks.find((block) => block.slug === blockFieldData.blockType)
// validate block
if (!block) {

View File

@@ -52,7 +52,7 @@ export const linkAfterReadPromiseHOC = (
promises,
req,
showHiddenFields,
siblingDoc,
siblingDoc: node.fields || {},
})
}
return promises

View File

@@ -51,7 +51,7 @@ export const uploadAfterReadPromiseHOC = (
promises,
req,
showHiddenFields,
siblingDoc,
siblingDoc: node.fields || {},
})
}
}

View File

@@ -0,0 +1,25 @@
import type { SerializedHeadingNode } from '@lexical/rich-text'
import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const HeadingConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
children: convertSlateNodesToLexical({
canContainParagraphs: false,
converters,
parentNodeType: 'heading',
slateNodes: slateNode.children || [],
}),
direction: 'ltr',
format: '',
indent: 0,
tag: slateNode.type as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', // Slate puts the tag (h1 / h2 / ...) inside of node.type
type: 'heading',
version: 1,
} as const as SerializedHeadingNode
},
nodeTypes: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
}

View File

@@ -0,0 +1,65 @@
import type { SerializedLexicalNode, SerializedParagraphNode } from 'lexical'
import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const IndentConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
console.log('slateToLexical > IndentConverter > converter', JSON.stringify(slateNode, null, 2))
const convertChildren = (node: any, indentLevel: number = 0): SerializedLexicalNode => {
if (
(node?.type && (!node.children || node.type !== 'indent')) ||
(!node?.type && node?.text)
) {
console.log(
'slateToLexical > IndentConverter > convertChildren > node',
JSON.stringify(node, null, 2),
)
console.log(
'slateToLexical > IndentConverter > convertChildren > nodeOutput',
JSON.stringify(
convertSlateNodesToLexical({
canContainParagraphs: false,
converters,
parentNodeType: 'indent',
slateNodes: [node],
}),
null,
2,
),
)
return {
...convertSlateNodesToLexical({
canContainParagraphs: false,
converters,
parentNodeType: 'indent',
slateNodes: [node],
})[0],
indent: indentLevel,
} as const as SerializedLexicalNode
}
const children = node.children.map((child: any) => convertChildren(child, indentLevel + 1))
console.log('slateToLexical > IndentConverter > children', JSON.stringify(children, null, 2))
return {
children: children,
direction: 'ltr',
format: '',
indent: indentLevel,
type: 'paragraph',
version: 1,
} as const as SerializedParagraphNode
}
console.log(
'slateToLexical > IndentConverter > output',
JSON.stringify(convertChildren(slateNode), null, 2),
)
return convertChildren(slateNode)
},
nodeTypes: ['indent'],
}

View File

@@ -0,0 +1,29 @@
import type { SerializedLinkNode } from '../../../../Link/nodes/LinkNode'
import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const LinkConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
children: convertSlateNodesToLexical({
canContainParagraphs: false,
converters,
parentNodeType: 'link',
slateNodes: slateNode.children || [],
}),
direction: 'ltr',
fields: {
doc: slateNode.doc || undefined,
linkType: slateNode.linkType || 'custom',
newTab: slateNode.newTab || false,
url: slateNode.url || undefined,
},
format: '',
indent: 0,
type: 'link',
version: 1,
} as const as SerializedLinkNode
},
nodeTypes: ['link'],
}

View File

@@ -0,0 +1,26 @@
import type { SerializedListItemNode } from '@lexical/list'
import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const ListItemConverter: SlateNodeConverter = {
converter({ childIndex, converters, slateNode }) {
return {
checked: undefined,
children: convertSlateNodesToLexical({
canContainParagraphs: false,
converters,
parentNodeType: 'listitem',
slateNodes: slateNode.children || [],
}),
direction: 'ltr',
format: '',
indent: 0,
type: 'listitem',
value: childIndex + 1,
version: 1,
} as const as SerializedListItemNode
},
nodeTypes: ['li'],
}

View File

@@ -0,0 +1,27 @@
import type { SerializedListNode } from '@lexical/list'
import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const OrderedListConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
children: convertSlateNodesToLexical({
canContainParagraphs: false,
converters,
parentNodeType: 'list',
slateNodes: slateNode.children || [],
}),
direction: 'ltr',
format: '',
indent: 0,
listType: 'number',
start: 1,
tag: 'ol',
type: 'list',
version: 1,
} as const as SerializedListNode
},
nodeTypes: ['ol'],
}

View File

@@ -0,0 +1,17 @@
import type { SerializedRelationshipNode } from '../../../../../..'
import type { SlateNodeConverter } from '../types'
export const RelationshipConverter: SlateNodeConverter = {
converter({ slateNode }) {
return {
format: '',
relationTo: slateNode.relationTo,
type: 'relationship',
value: {
id: slateNode?.value?.id || '',
},
version: 1,
} as const as SerializedRelationshipNode
},
nodeTypes: ['relationship'],
}

View File

@@ -0,0 +1,27 @@
import type { SerializedUnknownConvertedNode } from '../../nodes/unknownConvertedNode'
import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const UnknownConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
children: convertSlateNodesToLexical({
canContainParagraphs: false,
converters,
parentNodeType: 'unknownConverted',
slateNodes: slateNode.children || [],
}),
data: {
nodeData: slateNode,
nodeType: slateNode.type,
},
direction: 'ltr',
format: '',
indent: 0,
type: 'unknownConverted',
version: 1,
} as const as SerializedUnknownConvertedNode
},
nodeTypes: ['unknown'],
}

View File

@@ -0,0 +1,27 @@
import type { SerializedListNode } from '@lexical/list'
import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const UnorderedListConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
children: convertSlateNodesToLexical({
canContainParagraphs: false,
converters,
parentNodeType: 'list',
slateNodes: slateNode.children || [],
}),
direction: 'ltr',
format: '',
indent: 0,
listType: 'bullet',
start: 1,
tag: 'ul',
type: 'list',
version: 1,
} as const as SerializedListNode
},
nodeTypes: ['ul'],
}

View File

@@ -0,0 +1,20 @@
import type { SerializedUploadNode } from '../../../../../..'
import type { SlateNodeConverter } from '../types'
export const UploadConverter: SlateNodeConverter = {
converter({ slateNode }) {
return {
fields: {
...slateNode.fields,
},
format: '',
relationTo: slateNode.relationTo,
type: 'upload',
value: {
id: slateNode.value?.id || '',
},
version: 1,
} as const as SerializedUploadNode
},
nodeTypes: ['upload'],
}

View File

@@ -0,0 +1,23 @@
import type { SlateNodeConverter } from './types'
import { HeadingConverter } from './converters/heading'
import { IndentConverter } from './converters/indent'
import { LinkConverter } from './converters/link'
import { ListItemConverter } from './converters/listItem'
import { OrderedListConverter } from './converters/orderedList'
import { RelationshipConverter } from './converters/relationship'
import { UnknownConverter } from './converters/unknown'
import { UnorderedListConverter } from './converters/unorderedList'
import { UploadConverter } from './converters/upload'
export const defaultConverters: SlateNodeConverter[] = [
UnknownConverter,
UploadConverter,
UnorderedListConverter,
OrderedListConverter,
RelationshipConverter,
ListItemConverter,
LinkConverter,
HeadingConverter,
IndentConverter,
]

View File

@@ -0,0 +1,137 @@
import type {
SerializedEditorState,
SerializedLexicalNode,
SerializedParagraphNode,
SerializedTextNode,
} from 'lexical'
import type { SlateNode, SlateNodeConverter } from './types'
import { NodeFormat } from '../../../../lexical/utils/nodeFormat'
export function convertSlateToLexical({
converters,
slateData,
}: {
converters: SlateNodeConverter[]
slateData: SlateNode[]
}): SerializedEditorState {
return {
root: {
children: convertSlateNodesToLexical({
canContainParagraphs: true,
converters,
parentNodeType: 'root',
slateNodes: slateData,
}),
direction: 'ltr',
format: '',
indent: 0,
type: 'root',
version: 1,
},
}
}
export function convertSlateNodesToLexical({
canContainParagraphs,
converters,
parentNodeType,
slateNodes,
}: {
canContainParagraphs: boolean
converters: SlateNodeConverter[]
/**
* Type of the parent lexical node (not the type of the original, parent slate type)
*/
parentNodeType: string
slateNodes: SlateNode[]
}): SerializedLexicalNode[] {
const unknownConverter = converters.find((converter) => converter.nodeTypes.includes('unknown'))
return (
slateNodes.map((slateNode, i) => {
if (!('type' in slateNode)) {
if (canContainParagraphs) {
// This is a paragraph node. They do not have a type property in Slate
return convertParagraphNode(converters, slateNode)
} else {
// This is a simple text node. canContainParagraphs may be false if this is nested inside of a paragraph already, since paragraphs cannot contain paragraphs
return convertTextNode(slateNode)
}
}
if (slateNode.type === 'p') {
return convertParagraphNode(converters, slateNode)
}
const converter = converters.find((converter) => converter.nodeTypes.includes(slateNode.type))
if (converter) {
return converter.converter({ childIndex: i, converters, parentNodeType, slateNode })
}
console.warn('slateToLexical > No converter found for node type: ' + slateNode.type)
return unknownConverter?.converter({
childIndex: i,
converters,
parentNodeType,
slateNode,
})
}) || []
)
}
export function convertParagraphNode(
converters: SlateNodeConverter[],
node: SlateNode,
): SerializedParagraphNode {
return {
children: convertSlateNodesToLexical({
canContainParagraphs: false,
converters,
parentNodeType: 'paragraph',
slateNodes: node.children || [],
}),
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
}
}
export function convertTextNode(node: SlateNode): SerializedTextNode {
return {
detail: 0,
format: convertNodeToFormat(node),
mode: 'normal',
style: '',
text: node.text,
type: 'text',
version: 1,
}
}
export function convertNodeToFormat(node: SlateNode): number {
let format = 0
if (node.bold) {
format = format | NodeFormat.IS_BOLD
}
if (node.italic) {
format = format | NodeFormat.IS_ITALIC
}
if (node.strikethrough) {
format = format | NodeFormat.IS_STRIKETHROUGH
}
if (node.underline) {
format = format | NodeFormat.IS_UNDERLINE
}
if (node.subscript) {
format = format | NodeFormat.IS_SUBSCRIPT
}
if (node.superscript) {
format = format | NodeFormat.IS_SUPERSCRIPT
}
if (node.code) {
format = format | NodeFormat.IS_CODE
}
return format
}

View File

@@ -0,0 +1,22 @@
import type { SerializedLexicalNode } from 'lexical'
export type SlateNodeConverter<T extends SerializedLexicalNode = SerializedLexicalNode> = {
converter: ({
childIndex,
converters,
parentNodeType,
slateNode,
}: {
childIndex: number
converters: SlateNodeConverter[]
parentNodeType: string
slateNode: SlateNode
}) => T
nodeTypes: string[]
}
export type SlateNode = {
[key: string]: any
children?: SlateNode[]
type?: string // doesn't always have type, e.g. for paragraphs
}

View File

@@ -0,0 +1,56 @@
import type { FeatureProvider } from '../../types'
import type { SlateNodeConverter } from './converter/types'
import { convertSlateToLexical } from './converter'
import { defaultConverters } from './converter/defaultConverters'
import { UnknownConvertedNode } from './nodes/unknownConvertedNode'
type Props = {
converters?:
| (({ defaultConverters }: { defaultConverters: SlateNodeConverter[] }) => SlateNodeConverter[])
| SlateNodeConverter[]
}
export const SlateToLexicalFeature = (props?: Props): FeatureProvider => {
if (!props) {
props = {}
}
props.converters =
props?.converters && typeof props?.converters === 'function'
? props.converters({ defaultConverters: defaultConverters })
: (props?.converters as SlateNodeConverter[]) || defaultConverters
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
hooks: {
load({ incomingEditorState }) {
if (
!incomingEditorState ||
!Array.isArray(incomingEditorState) ||
'root' in incomingEditorState
) {
// incomingEditorState null or not from Slate
return incomingEditorState
}
// Slate => convert to lexical
return convertSlateToLexical({
converters: props.converters as SlateNodeConverter[],
slateData: incomingEditorState,
})
},
},
nodes: [
{
node: UnknownConvertedNode,
type: UnknownConvertedNode.getType(),
},
],
props,
}
},
key: 'slateToLexical',
}
}

View File

@@ -0,0 +1,16 @@
@import 'payload/scss';
span.unknownConverted {
text-transform: uppercase;
font-family: 'Roboto Mono', monospace;
letter-spacing: 2px;
font-size: base(0.5);
margin: 0 0 base(1);
background: red;
color: white;
display: inline-block;
div {
background: red;
}
}

View File

@@ -0,0 +1,97 @@
import type { SerializedLexicalNode, Spread } from 'lexical'
import { addClassNamesToElement } from '@lexical/utils'
import { DecoratorNode, type EditorConfig, type LexicalNode, type NodeKey } from 'lexical'
import React from 'react'
import './index.scss'
export type UnknownConvertedNodeData = {
nodeData: unknown
nodeType: string
}
export type SerializedUnknownConvertedNode = Spread<
{
data: UnknownConvertedNodeData
},
SerializedLexicalNode
>
/** @noInheritDoc */
export class UnknownConvertedNode extends DecoratorNode<JSX.Element> {
__data: UnknownConvertedNodeData
constructor({ data, key }: { data: UnknownConvertedNodeData; key?: NodeKey }) {
super(key)
this.__data = data
}
static clone(node: UnknownConvertedNode): UnknownConvertedNode {
return new UnknownConvertedNode({
data: node.__data,
key: node.__key,
})
}
static getType(): string {
return 'unknownConverted'
}
static importJSON(serializedNode: SerializedUnknownConvertedNode): UnknownConvertedNode {
const node = $createUnknownConvertedNode({ data: serializedNode.data })
return node
}
canInsertTextAfter(): true {
return true
}
canInsertTextBefore(): true {
return true
}
createDOM(config: EditorConfig): HTMLElement {
const element = document.createElement('span')
addClassNamesToElement(element, 'unknownConverted')
return element
}
decorate(): JSX.Element | null {
return <div>Unknown converted Slate node: {this.__data?.nodeType}</div>
}
exportJSON(): SerializedUnknownConvertedNode {
return {
data: this.__data,
type: this.getType(),
version: 1,
}
}
// Mutation
isInline(): boolean {
return true
}
updateDOM(prevNode: UnknownConvertedNode, dom: HTMLElement): boolean {
return false
}
}
export function $createUnknownConvertedNode({
data,
}: {
data: UnknownConvertedNodeData
}): UnknownConvertedNode {
return new UnknownConvertedNode({
data,
})
}
export function $isUnknownConvertedNode(
node: LexicalNode | null | undefined,
): node is UnknownConvertedNode {
return node instanceof UnknownConvertedNode
}

View File

@@ -51,6 +51,18 @@ export type Feature = {
floatingSelectToolbar?: {
sections: FloatingToolbarSection[]
}
hooks?: {
load?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
save?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
}
markdownTransformers?: Transformer[]
nodes?: Array<{
afterReadPromises?: Array<AfterReadPromise>
@@ -123,6 +135,22 @@ export type SanitizedFeatures = Required<
floatingSelectToolbar: {
sections: FloatingToolbarSection[]
}
hooks: {
load: Array<
({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
>
save: Array<
({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
>
}
plugins?: Array<
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality

View File

@@ -21,7 +21,16 @@ export type LexicalProviderProps = {
value: SerializedEditorState
}
export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => {
const { editorConfig, fieldProps, initialState, onChange, readOnly, setValue, value } = props
const { editorConfig, fieldProps, onChange, readOnly, setValue } = props
let { initialState, value } = props
// Transform initialState through load hooks
if (editorConfig?.features?.hooks?.load?.length) {
editorConfig.features.hooks.load.forEach((hook) => {
initialState = hook({ incomingEditorState: initialState })
value = hook({ incomingEditorState: value })
})
}
if (
(value && Array.isArray(value) && !('root' in value)) ||

View File

@@ -10,6 +10,10 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
floatingSelectToolbar: {
sections: [],
},
hooks: {
load: [],
save: [],
},
markdownTransformers: [],
nodes: [],
plugins: [],
@@ -21,6 +25,15 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
}
features.forEach((feature) => {
if (feature.hooks) {
if (feature.hooks?.load?.length) {
sanitized.hooks.load = sanitized.hooks.load.concat(feature.hooks.load)
}
if (feature.hooks?.save?.length) {
sanitized.hooks.save = sanitized.hooks.save.concat(feature.hooks.save)
}
}
if (feature.nodes?.length) {
sanitized.nodes = sanitized.nodes.concat(feature.nodes)
feature.nodes.forEach((node) => {

View File

@@ -0,0 +1,124 @@
/* eslint-disable perfectionist/sort-objects */
/* eslint-disable regexp/no-obscure-range */
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */
//This copy-and-pasted from lexical here here: https://github.com/facebook/lexical/blob/c2ceee223f46543d12c574e62155e619f9a18a5d/packages/lexical/src/LexicalConstants.ts
import type { ElementFormatType, TextFormatType } from 'lexical'
import type { TextDetailType, TextModeType } from 'lexical/nodes/LexicalTextNode'
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// DOM
export const NodeFormat = {
DOM_ELEMENT_TYPE: 1,
DOM_TEXT_TYPE: 3,
// Reconciling
NO_DIRTY_NODES: 0,
HAS_DIRTY_NODES: 1,
FULL_RECONCILE: 2,
// Text node modes
IS_NORMAL: 0,
IS_TOKEN: 1,
IS_SEGMENTED: 2,
IS_INERT: 3,
// Text node formatting
IS_BOLD: 1,
IS_ITALIC: 1 << 1,
IS_STRIKETHROUGH: 1 << 2,
IS_UNDERLINE: 1 << 3,
IS_CODE: 1 << 4,
IS_SUBSCRIPT: 1 << 5,
IS_SUPERSCRIPT: 1 << 6,
IS_HIGHLIGHT: 1 << 7,
// Text node details
IS_DIRECTIONLESS: 1,
IS_UNMERGEABLE: 1 << 1,
// Element node formatting
IS_ALIGN_LEFT: 1,
IS_ALIGN_CENTER: 2,
IS_ALIGN_RIGHT: 3,
IS_ALIGN_JUSTIFY: 4,
IS_ALIGN_START: 5,
IS_ALIGN_END: 6,
} as const
export const IS_ALL_FORMATTING =
NodeFormat.IS_BOLD |
NodeFormat.IS_ITALIC |
NodeFormat.IS_STRIKETHROUGH |
NodeFormat.IS_UNDERLINE |
NodeFormat.IS_CODE |
NodeFormat.IS_SUBSCRIPT |
NodeFormat.IS_SUPERSCRIPT |
NodeFormat.IS_HIGHLIGHT
// Reconciliation
export const NON_BREAKING_SPACE = '\u00A0'
export const DOUBLE_LINE_BREAK = '\n\n'
// For FF, we need to use a non-breaking space, or it gets composition
// in a stuck state.
const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC'
const LTR =
'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' +
'\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' +
'\uFE00-\uFE6F\uFEFD-\uFFFF'
// eslint-disable-next-line no-misleading-character-class
export const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']')
// eslint-disable-next-line no-misleading-character-class
export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']')
export const TEXT_TYPE_TO_FORMAT: Record<TextFormatType | string, number> = {
bold: NodeFormat.IS_BOLD,
code: NodeFormat.IS_CODE,
highlight: NodeFormat.IS_HIGHLIGHT,
italic: NodeFormat.IS_ITALIC,
strikethrough: NodeFormat.IS_STRIKETHROUGH,
subscript: NodeFormat.IS_SUBSCRIPT,
superscript: NodeFormat.IS_SUPERSCRIPT,
underline: NodeFormat.IS_UNDERLINE,
}
export const DETAIL_TYPE_TO_DETAIL: Record<TextDetailType | string, number> = {
directionless: NodeFormat.IS_DIRECTIONLESS,
unmergeable: NodeFormat.IS_UNMERGEABLE,
}
export const ELEMENT_TYPE_TO_FORMAT: Record<Exclude<ElementFormatType, ''>, number> = {
center: NodeFormat.IS_ALIGN_CENTER,
end: NodeFormat.IS_ALIGN_END,
justify: NodeFormat.IS_ALIGN_JUSTIFY,
left: NodeFormat.IS_ALIGN_LEFT,
right: NodeFormat.IS_ALIGN_RIGHT,
start: NodeFormat.IS_ALIGN_START,
}
export const ELEMENT_FORMAT_TO_TYPE: Record<number, ElementFormatType> = {
[NodeFormat.IS_ALIGN_CENTER]: 'center',
[NodeFormat.IS_ALIGN_END]: 'end',
[NodeFormat.IS_ALIGN_JUSTIFY]: 'justify',
[NodeFormat.IS_ALIGN_LEFT]: 'left',
[NodeFormat.IS_ALIGN_RIGHT]: 'right',
[NodeFormat.IS_ALIGN_START]: 'start',
}
export const TEXT_MODE_TO_TYPE: Record<TextModeType, 0 | 1 | 2> = {
normal: NodeFormat.IS_NORMAL,
segmented: NodeFormat.IS_SEGMENTED,
token: NodeFormat.IS_TOKEN,
}
export const TEXT_TYPE_TO_MODE: Record<number, TextModeType> = {
[NodeFormat.IS_NORMAL]: 'normal',
[NodeFormat.IS_SEGMENTED]: 'segmented',
[NodeFormat.IS_TOKEN]: 'token',
}

View File

@@ -153,6 +153,7 @@ export { IndentFeature } from './field/features/indent'
export { CheckListFeature } from './field/features/lists/CheckList'
export { OrderedListFeature } from './field/features/lists/OrderedList'
export { UnoderedListFeature } from './field/features/lists/UnorderedList'
export { SlateToLexicalFeature } from './field/features/migrations/SlateToLexical'
export type {
AfterReadPromise,
Feature,
@@ -201,6 +202,20 @@ export { isHTMLElement } from './field/lexical/utils/guard'
export { invariant } from './field/lexical/utils/invariant'
export { joinClasses } from './field/lexical/utils/joinClasses'
export { createBlockNode } from './field/lexical/utils/markdown/createBlockNode'
export {
DETAIL_TYPE_TO_DETAIL,
DOUBLE_LINE_BREAK,
ELEMENT_FORMAT_TO_TYPE,
ELEMENT_TYPE_TO_FORMAT,
IS_ALL_FORMATTING,
LTR_REGEX,
NON_BREAKING_SPACE,
NodeFormat,
RTL_REGEX,
TEXT_MODE_TO_TYPE,
TEXT_TYPE_TO_FORMAT,
TEXT_TYPE_TO_MODE,
} from './field/lexical/utils/nodeFormat'
export { Point, isPoint } from './field/lexical/utils/point'
export { Rect } from './field/lexical/utils/rect'
export { setFloatingElemPosition } from './field/lexical/utils/setFloatingElemPosition'

View File

@@ -173,7 +173,7 @@ export const recurseNestedFields = ({
promises,
req,
showHiddenFields,
siblingDoc,
siblingDoc: data[field.name][i], // This has to be scoped to the blocks's fields, otherwise there may be population issues, e.g. for a relationship field with Blocks Node, with a Blocks Field, with a RichText Field, With Relationship Node. The last richtext field would try to find itself using siblingDoc[field.nane], which only works if the siblingDoc is scoped to the blocks's fields
})
}
})
@@ -191,14 +191,13 @@ export const recurseNestedFields = ({
promises,
req,
showHiddenFields,
siblingDoc,
siblingDoc, // TODO: if there's any population issues, this might have to be data[field.name][i] as well
})
})
}
}
if (field.type === 'richText') {
// TODO: This does not properly work yet. E.g. it does not handle a relationship inside of lexical inside of block inside of lexical
const editor: RichTextAdapter = field?.editor
if (editor?.afterReadPromise) {

View File

@@ -59,7 +59,7 @@ export const recurseRichText = ({
}
}
if ('children' in node && node?.children) {
if ('children' in node && Array.isArray(node?.children) && node?.children?.length) {
recurseRichText({
afterReadPromises,
children: node.children as SerializedLexicalNode[],

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-slate",
"version": "1.0.2",
"version": "1.0.3",
"description": "The officially supported Slate richtext adapter for Payload",
"repository": "https://github.com/payloadcms/payload",
"license": "MIT",

View File

@@ -1,3 +1,4 @@
'use client'
import type { CellComponentProps, RichTextField } from 'payload/types'
import React from 'react'

View File

@@ -1,3 +1,4 @@
'use client'
import type { ElementType } from 'react'
import { Tooltip } from 'payload/components'

View File

@@ -1,3 +1,4 @@
'use client'
import { Editor, Transforms } from 'slate'
import { ReactEditor } from 'slate-react'

View File

@@ -1,3 +1,4 @@
'use client'
import { ShimmerEffect } from 'payload/components'
import React, { Suspense, lazy } from 'react'

View File

@@ -1,3 +1,4 @@
'use client'
import React from 'react'
import { useSlate } from 'slate-react'

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env bash
# List all published packages
packages=$(find packages -name package.json -type f -exec grep -L '"private": true' {} \; | xargs jq -r '.name')
# sort alphabetically
packages=$(echo "$packages" | tr ' ' '\n' | sort -u | tr '\n' ' ')
# Loop through each package and print the name and version. Print as table
printf "%-30s %-20s %-20s\n" "package" "latest" "beta"
for package in $packages; do
info=$(npm view "$package" dist-tags --json)
latest=$(echo "$info" | jq -r '.latest')
beta=$(echo "$info" | jq -r '.beta')
printf "%-30s %-20s %-20s\n" "$package" "$latest" "$beta"
done

View File

@@ -1,26 +0,0 @@
#!/usr/bin/env bash
set -ex
# Build packages/payload
package_name=$1
package_dir="packages/$package_name"
if [ -z "$package_name" ]; then
echo "Please specify a package to publish"
exit 1
fi
# Check if packages/$package_name exists
if [ ! -d "$package_dir" ]; then
echo "Package $package_name does not exist"
exit 1
fi
npm --prefix "$package_dir" version pre --preid beta
git add "$package_dir"/package.json
new_version=$(node -p "require('./$package_dir/package.json').version")
git commit -m "chore(release): $package_name@$new_version"
pnpm publish -C "$package_dir" --tag beta --no-git-checks

View File

@@ -0,0 +1,23 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
import CustomEditView from '../components/views/CustomEdit'
export const CustomViews1: CollectionConfig = {
slug: 'custom-views-one',
versions: true,
admin: {
components: {
views: {
// This will override the entire Edit view including all nested views, i.e. `/edit/:id/*`
// To override one specific nested view, use the nested view's slug as the key
Edit: CustomEditView,
},
},
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,41 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
import CustomTabComponent from '../components/CustomTabComponent'
import CustomDefaultEditView from '../components/views/CustomDefaultEdit'
import CustomVersionsView from '../components/views/CustomVersions'
import CustomView from '../components/views/CustomView'
export const CustomViews2: CollectionConfig = {
slug: 'custom-views-two',
versions: true,
admin: {
components: {
views: {
Edit: {
// This will override one specific nested view within the `/edit/:id` route, i.e. `/edit/:id/versions`
Default: CustomDefaultEditView,
Versions: CustomVersionsView,
MyCustomView: {
path: '/custom-tab-view',
Component: CustomView,
Tab: {
label: 'Custom',
href: '/custom-tab-view',
},
},
MyCustomViewWithCustomTab: {
path: '/custom-tab-component',
Component: CustomView,
Tab: CustomTabComponent,
},
},
},
},
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,11 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
export const Geo: CollectionConfig = {
slug: 'geo',
fields: [
{
name: 'point',
type: 'point',
},
],
}

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
import { group1Collection1Slug } from '../shared'
export const CollectionGroup1A: CollectionConfig = {
slug: group1Collection1Slug,
admin: {
group: 'One',
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,16 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
import { group1Collection2Slug } from '../shared'
export const CollectionGroup1B: CollectionConfig = {
slug: group1Collection2Slug,
admin: {
group: 'One',
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,14 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
export const CollectionGroup2A: CollectionConfig = {
slug: 'group-two-collection-ones',
admin: {
group: 'One',
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,14 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
export const CollectionGroup2B: CollectionConfig = {
slug: 'group-two-collection-twos',
admin: {
group: 'One',
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,14 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
export const CollectionHidden: CollectionConfig = {
slug: 'hidden-collection',
admin: {
hidden: () => true,
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,96 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
import { slateEditor } from '../../../packages/richtext-slate/src'
import DemoUIFieldCell from '../components/DemoUIField/Cell'
import DemoUIFieldField from '../components/DemoUIField/Field'
import { postsSlug, slugPluralLabel, slugSingularLabel } from '../shared'
export const Posts: CollectionConfig = {
slug: postsSlug,
labels: {
singular: slugSingularLabel,
plural: slugPluralLabel,
},
admin: {
description: 'Description',
listSearchableFields: ['title', 'description', 'number'],
group: 'One',
useAsTitle: 'title',
defaultColumns: ['id', 'number', 'title', 'description', 'demoUIField'],
preview: () => 'https://payloadcms.com',
},
versions: {
drafts: true,
},
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Tab 1',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
name: 'richText',
type: 'richText',
editor: slateEditor({
admin: {
elements: ['relationship'],
},
}),
},
{
type: 'ui',
name: 'demoUIField',
label: 'Demo UI Field',
admin: {
components: {
Field: DemoUIFieldField,
Cell: DemoUIFieldCell,
},
},
},
],
},
],
},
{
name: 'group',
type: 'group',
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
name: 'relationship',
type: 'relationship',
relationTo: 'posts',
admin: {
position: 'sidebar',
},
},
{
name: 'sidebarField',
type: 'text',
admin: {
position: 'sidebar',
description:
'This is a very long description that takes many characters to complete and hopefully will wrap instead of push the sidebar open, lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum voluptates. Quisquam, voluptatum voluptates.',
},
},
],
}

View File

@@ -0,0 +1,10 @@
import type { CollectionConfig } from '../../../packages/payload/src/collections/config/types'
export const Users: CollectionConfig = {
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
fields: [],
}

View File

@@ -6,7 +6,7 @@ import type { AdminViewComponent } from '../../../../../packages/payload/src/con
import { useStepNav } from '../../../../../packages/payload/src/admin/components/elements/StepNav'
import { useConfig } from '../../../../../packages/payload/src/admin/components/utilities/Config'
const CustomDefaultView: AdminViewComponent = ({
const CustomDefaultEditView: AdminViewComponent = ({
canAccessAdmin,
// collection,
// global,
@@ -72,4 +72,4 @@ const CustomDefaultView: AdminViewComponent = ({
)
}
export default CustomDefaultView
export default CustomDefaultEditView

View File

@@ -1,23 +1,31 @@
import path from 'path'
import { mapAsync } from '../../packages/payload/src/utilities/mapAsync'
import { slateEditor } from '../../packages/richtext-slate/src'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults'
import { devUser } from '../credentials'
import { CustomViews1 } from './collections/CustomViews1'
import { CustomViews2 } from './collections/CustomViews2'
import { Geo } from './collections/Geo'
import { CollectionGroup1A } from './collections/Group1A'
import { CollectionGroup1B } from './collections/Group1B'
import { CollectionGroup2A } from './collections/Group2A'
import { CollectionGroup2B } from './collections/Group2B'
import { CollectionHidden } from './collections/Hidden'
import { Posts } from './collections/Posts'
import { Users } from './collections/Users'
import AfterDashboard from './components/AfterDashboard'
import AfterNavLinks from './components/AfterNavLinks'
import BeforeLogin from './components/BeforeLogin'
import CustomTabComponent from './components/CustomTabComponent'
import DemoUIFieldCell from './components/DemoUIField/Cell'
import DemoUIFieldField from './components/DemoUIField/Field'
import Logout from './components/Logout'
import CustomDefaultView from './components/views/CustomDefault'
import CustomDefaultEditView from './components/views/CustomDefaultEdit'
import CustomEditView from './components/views/CustomEdit'
import CustomMinimalRoute from './components/views/CustomMinimal'
import CustomVersionsView from './components/views/CustomVersions'
import CustomView from './components/views/CustomView'
import { globalSlug, postsSlug, slugPluralLabel, slugSingularLabel } from './shared'
import { CustomGlobalViews1 } from './globals/CustomViews1'
import { CustomGlobalViews2 } from './globals/CustomViews2'
import { Global } from './globals/Global'
import { GlobalGroup1A } from './globals/Group1A'
import { GlobalGroup1B } from './globals/Group1B'
import { GlobalHidden } from './globals/Hidden'
import { postsSlug } from './shared'
export interface Post {
createdAt: Date
@@ -66,321 +74,24 @@ export default buildConfigWithDefaults({
locales: ['en', 'es'],
},
collections: [
{
slug: 'users',
auth: true,
admin: {
useAsTitle: 'email',
},
fields: [],
},
{
slug: 'hidden-collection',
admin: {
hidden: () => true,
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: postsSlug,
labels: {
singular: slugSingularLabel,
plural: slugPluralLabel,
},
admin: {
description: 'Description',
listSearchableFields: ['title', 'description', 'number'],
group: 'One',
useAsTitle: 'title',
defaultColumns: ['id', 'number', 'title', 'description', 'demoUIField'],
preview: () => 'https://payloadcms.com',
},
versions: {
drafts: true,
},
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Tab 1',
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'description',
type: 'text',
},
{
name: 'number',
type: 'number',
},
{
name: 'richText',
type: 'richText',
editor: slateEditor({
admin: {
elements: ['relationship'],
},
}),
},
{
type: 'ui',
name: 'demoUIField',
label: 'Demo UI Field',
admin: {
components: {
Field: DemoUIFieldField,
Cell: DemoUIFieldCell,
},
},
},
],
},
],
},
{
name: 'sidebarField',
type: 'text',
admin: {
position: 'sidebar',
description:
'This is a very long description that takes many characters to complete and hopefully will wrap instead of push the sidebar open, lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum voluptates. Quisquam, voluptatum voluptates.',
},
},
],
},
{
slug: 'custom-views-one',
versions: true,
admin: {
components: {
views: {
// This will override the entire Edit view including all nested views, i.e. `/edit/:id/*`
// To override one specific nested view, use the nested view's slug as the key
Edit: CustomEditView,
},
},
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'custom-views-two',
versions: true,
admin: {
components: {
views: {
Edit: {
// This will override one specific nested view within the `/edit/:id` route, i.e. `/edit/:id/versions`
Default: CustomDefaultEditView,
Versions: CustomVersionsView,
MyCustomView: {
path: '/custom-tab-view',
Component: CustomView,
Tab: {
label: 'Custom',
href: '/custom-tab-view',
},
},
MyCustomViewWithCustomTab: {
path: '/custom-tab-component',
Component: CustomView,
Tab: CustomTabComponent,
},
},
},
},
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-one-collection-ones',
admin: {
group: 'One',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-one-collection-twos',
admin: {
group: 'One',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-two-collection-ones',
admin: {
group: 'Two',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-two-collection-twos',
admin: {
group: 'Two',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'geo',
fields: [
{
name: 'point',
type: 'point',
},
],
},
Posts,
Users,
CollectionHidden,
CustomViews1,
CustomViews2,
CollectionGroup1A,
CollectionGroup1B,
CollectionGroup2A,
CollectionGroup2B,
Geo,
],
globals: [
{
slug: 'hidden-global',
admin: {
hidden: () => true,
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: globalSlug,
label: {
en: 'My Global Label',
},
admin: {
group: 'Group',
},
versions: {
drafts: true,
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'sidebarField',
type: 'text',
admin: {
position: 'sidebar',
},
},
],
},
{
slug: 'custom-global-views-one',
versions: true,
admin: {
components: {
views: {
Edit: CustomEditView,
},
},
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'custom-global-views-two',
versions: true,
admin: {
components: {
views: {
Edit: {
Default: CustomDefaultEditView,
Versions: CustomVersionsView,
MyCustomView: {
path: '/custom-tab-view',
Component: CustomView,
Tab: {
label: 'Custom',
href: '/custom-tab-view',
},
},
MyCustomViewWithCustomTab: {
path: '/custom-tab-component',
Component: CustomView,
Tab: CustomTabComponent,
},
},
},
},
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-globals-one',
label: 'Group Globals 1',
admin: {
group: 'Group',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
{
slug: 'group-globals-two',
admin: {
group: 'Group',
},
fields: [
{
name: 'title',
type: 'text',
},
],
},
GlobalHidden,
Global,
CustomGlobalViews1,
CustomGlobalViews2,
GlobalGroup1A,
GlobalGroup1B,
],
onInit: async (payload) => {
await payload.create({

View File

@@ -17,7 +17,13 @@ import {
} from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers'
import { globalSlug, postsSlug, slugPluralLabel } from './shared'
import {
globalSlug,
group1Collection1Slug,
group1GlobalSlug,
postsSlug,
slugPluralLabel,
} from './shared'
const { afterEach, beforeAll, beforeEach, describe } = test
@@ -149,6 +155,38 @@ describe('admin', () => {
})
})
describe('ui', () => {
test('collection - should render preview button when `admin.preview` is set', async () => {
const collectionWithPreview = new AdminUrlUtil(serverURL, postsSlug)
await page.goto(collectionWithPreview.create)
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await expect(page.locator('.btn.preview-btn')).toBeVisible()
})
test('collection - should not render preview button when `admin.preview` is not set', async () => {
const collectionWithoutPreview = new AdminUrlUtil(serverURL, group1Collection1Slug)
await page.goto(collectionWithoutPreview.create)
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await expect(page.locator('.btn.preview-btn')).toBeHidden()
})
test('global - should render preview button when `admin.preview` is set', async () => {
const globalWithPreview = new AdminUrlUtil(serverURL, globalSlug)
await page.goto(globalWithPreview.global(globalSlug))
await expect(page.locator('.btn.preview-btn')).toBeVisible()
})
test('global - should not render preview button when `admin.preview` is not set', async () => {
const globalWithoutPreview = new AdminUrlUtil(serverURL, group1GlobalSlug)
await page.goto(globalWithoutPreview.global(group1GlobalSlug))
await page.locator('#field-title').fill(title)
await saveDocAndAssert(page)
await expect(page.locator('.btn.preview-btn')).toBeHidden()
})
})
describe('doc titles', () => {
test('collection - should render fallback titles when creating new', async () => {
await page.goto(url.create)
@@ -292,10 +330,12 @@ describe('admin', () => {
await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').click()
const options = page.locator('.rs__option')
const titleOption = options.locator('text=Title')
await expect(titleOption).toHaveText('Title')
const titleOption = page.locator('.rs__option', {
hasText: exactText('Title'),
})
await expect(titleOption).toBeVisible()
await titleOption.click()
const titleInput = page.locator('#field-title')

View File

@@ -0,0 +1,21 @@
import type { GlobalConfig } from '../../../packages/payload/src/globals/config/types'
import CustomEditView from '../components/views/CustomEdit'
export const CustomGlobalViews1: GlobalConfig = {
slug: 'custom-global-views-one',
versions: true,
admin: {
components: {
views: {
Edit: CustomEditView,
},
},
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,40 @@
import type { GlobalConfig } from '../../../packages/payload/src/globals/config/types'
import CustomTabComponent from '../components/CustomTabComponent'
import CustomDefaultEditView from '../components/views/CustomDefaultEdit'
import CustomVersionsView from '../components/views/CustomVersions'
import CustomView from '../components/views/CustomView'
export const CustomGlobalViews2: GlobalConfig = {
slug: 'custom-global-views-two',
versions: true,
admin: {
components: {
views: {
Edit: {
Default: CustomDefaultEditView,
Versions: CustomVersionsView,
MyCustomView: {
path: '/custom-tab-view',
Component: CustomView,
Tab: {
label: 'Custom',
href: '/custom-tab-view',
},
},
MyCustomViewWithCustomTab: {
path: '/custom-tab-component',
Component: CustomView,
Tab: CustomTabComponent,
},
},
},
},
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,30 @@
import type { GlobalConfig } from '../../../packages/payload/src/globals/config/types'
import { globalSlug } from '../shared'
export const Global: GlobalConfig = {
slug: globalSlug,
label: {
en: 'My Global Label',
},
admin: {
group: 'Group',
preview: () => 'https://payloadcms.com',
},
versions: {
drafts: true,
},
fields: [
{
name: 'title',
type: 'text',
},
{
name: 'sidebarField',
type: 'text',
admin: {
position: 'sidebar',
},
},
],
}

View File

@@ -0,0 +1,17 @@
import type { GlobalConfig } from '../../../packages/payload/src/globals/config/types'
import { group1GlobalSlug } from '../shared'
export const GlobalGroup1A: GlobalConfig = {
slug: group1GlobalSlug,
label: 'Group Globals 1',
admin: {
group: 'Group',
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,14 @@
import type { GlobalConfig } from '../../../packages/payload/src/globals/config/types'
export const GlobalGroup1B: GlobalConfig = {
slug: 'group-globals-two',
admin: {
group: 'Group',
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -0,0 +1,14 @@
import type { GlobalConfig } from '../../../packages/payload/src/globals/config/types'
export const GlobalHidden: GlobalConfig = {
slug: 'hidden-global',
admin: {
hidden: () => true,
},
fields: [
{
name: 'title',
type: 'text',
},
],
}

View File

@@ -1,7 +1,13 @@
export const postsSlug = 'posts'
export const group1Collection1Slug = 'group-one-collection-ones'
export const group1Collection2Slug = 'group-one-collection-twos'
export const slugSingularLabel = 'Post'
export const slugPluralLabel = 'Posts'
export const globalSlug = 'global'
export const group1GlobalSlug = 'group-globals-one'

View File

@@ -8,7 +8,7 @@ const baseClass = 'custom-blocks-field-management'
export const AddCustomBlocks: React.FC = () => {
const { addFieldRow, replaceFieldRow } = useForm()
const { value } = useField({ path: 'customBlocks' })
const { value } = useField<number>({ path: 'customBlocks' })
return (
<div className={baseClass}>
@@ -47,12 +47,12 @@ export const AddCustomBlocks: React.FC = () => {
replaceFieldRow({
data: { block1Title: 'REPLACED BLOCK', blockType: 'block-1' },
path: 'customBlocks',
rowIndex: (Array.isArray(value) ? value.length : 0) - 1,
rowIndex: value - 1,
})
}
type="button"
>
Replace Block {Array.isArray(value) ? value.length : 0}
Replace Block {value}
</button>
</div>
</div>

View File

@@ -83,6 +83,16 @@ const ArrayFields: CollectionConfig = {
type: 'text',
name: 'text',
},
{
type: 'group',
name: 'groupInRow',
fields: [
{
type: 'text',
name: 'textInGroupInRow',
},
],
},
],
},
{

View File

@@ -0,0 +1,119 @@
import type { Block } from '../../../../packages/payload/src/fields/config/types'
import { lexicalEditor } from '../../../../packages/richtext-lexical/src'
export const TextBlock: Block = {
fields: [
{
name: 'text',
type: 'text',
required: true,
},
],
slug: 'text',
}
export const RichTextBlock: Block = {
fields: [
{
name: 'richText',
type: 'richText',
editor: lexicalEditor(),
},
],
slug: 'richText',
}
export const UploadAndRichTextBlock: Block = {
fields: [
{
name: 'upload',
type: 'upload',
relationTo: 'uploads',
required: true,
},
{
name: 'richText',
type: 'richText',
editor: lexicalEditor(),
},
],
slug: 'uploadAndRichText',
}
export const RelationshipBlock: Block = {
fields: [
{
name: 'rel',
type: 'relationship',
relationTo: 'uploads',
required: true,
},
],
slug: 'relationshipBlock',
}
export const SelectFieldBlock: Block = {
fields: [
{
name: 'select',
type: 'select',
options: [
{
label: 'Option 1',
value: 'option1',
},
{
label: 'Option 2',
value: 'option2',
},
{
label: 'Option 3',
value: 'option3',
},
{
label: 'Option 4',
value: 'option4',
},
{
label: 'Option 5',
value: 'option5',
},
],
},
],
slug: 'select',
}
export const SubBlockBlock: Block = {
slug: 'subBlock',
fields: [
{
name: 'subBlocks',
type: 'blocks',
blocks: [
{
slug: 'contentBlock',
fields: [
{
name: 'richText',
type: 'richText',
required: true,
editor: lexicalEditor(),
},
],
},
{
slug: 'textArea',
fields: [
{
name: 'content',
type: 'textarea',
required: true,
},
],
},
],
},
],
}

View File

@@ -0,0 +1,193 @@
export function generateLexicalRichText() {
return {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Upload Node:',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'upload',
version: 1,
fields: {
caption: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
children: [
{
detail: 0,
format: 0,
mode: 'normal',
style: '',
text: 'Relationship inside Upload Caption:',
type: 'text',
version: 1,
},
],
direction: 'ltr',
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
format: '',
type: 'relationship',
version: 1,
relationTo: 'text-fields',
value: {
id: '{{TEXT_DOC_ID}}',
},
},
],
direction: 'ltr',
},
},
},
relationTo: 'uploads',
value: {
id: '{{UPLOAD_DOC_ID}}',
},
},
{
format: '',
type: 'block',
version: 1,
fields: {
data: {
id: '65298b13db4ef8c744a7faaa',
rel: '{{UPLOAD_DOC_ID}}',
blockName: 'Block Node, with Relationship Field',
blockType: 'relationshipBlock',
},
},
},
{
format: '',
type: 'block',
version: 1,
fields: {
data: {
id: '65298b1ddb4ef8c744a7faab',
richText: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
format: '',
type: 'relationship',
version: 1,
relationTo: 'text-fields',
value: {
id: '{{TEXT_DOC_ID}}',
},
},
],
direction: null,
},
},
blockName: 'Block Node, with RichText Field, with Relationship Node',
blockType: 'richText',
},
},
},
{
format: '',
type: 'block',
version: 1,
fields: {
data: {
id: '65298b2bdb4ef8c744a7faac',
blockName:
'Block Node, with Blocks Field, With RichText Field, With Relationship Node',
blockType: 'subBlock',
subBlocks: [
{
id: '65298b2edb4ef8c744a7faad',
richText: {
root: {
type: 'root',
format: '',
indent: 0,
version: 1,
children: [
{
format: '',
type: 'relationship',
version: 1,
relationTo: 'text-fields',
value: {
id: '{{TEXT_DOC_ID}}',
},
},
],
direction: null,
},
},
blockType: 'contentBlock',
},
],
},
},
},
{
format: '',
type: 'block',
version: 1,
fields: {
data: {
id: '65298b49db4ef8c744a7faae',
upload: '{{UPLOAD_DOC_ID}}',
blockName: 'Block Node, With Upload Field',
blockType: 'uploadAndRichText',
},
},
},
{
children: [],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
{
children: [],
direction: null,
format: '',
indent: 0,
type: 'paragraph',
version: 1,
},
],
direction: 'ltr',
},
}
}

View File

@@ -0,0 +1,90 @@
import type { CollectionConfig } from '../../../../packages/payload/src/collections/config/types'
import {
BlocksFeature,
LinkFeature,
TreeviewFeature,
UploadFeature,
lexicalEditor,
} from '../../../../packages/richtext-lexical/src'
import {
RelationshipBlock,
RichTextBlock,
SelectFieldBlock,
SubBlockBlock,
TextBlock,
UploadAndRichTextBlock,
} from './blocks'
import { generateLexicalRichText } from './generateLexicalRichText'
export const LexicalFields: CollectionConfig = {
slug: 'lexical-fields',
admin: {
useAsTitle: 'title',
listSearchableFields: ['title', 'richTextLexicalCustomFields'],
},
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
required: true,
},
{
name: 'richTextLexicalCustomFields',
type: 'richText',
required: true,
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeviewFeature(),
LinkFeature({
fields: [
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
BlocksFeature({
blocks: [
RichTextBlock,
TextBlock,
UploadAndRichTextBlock,
SelectFieldBlock,
RelationshipBlock,
SubBlockBlock,
],
}),
],
}),
},
],
}
export const LexicalRichTextDoc = {
title: 'Rich Text',
richTextLexicalCustomFields: generateLexicalRichText(),
}

View File

@@ -0,0 +1,2 @@
export const loremIpsum =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam hendrerit nisi sed sollicitudin pellentesque. Nunc posuere purus rhoncus pulvinar aliquam. Ut aliquet tristique nisl vitae volutpat. Nulla aliquet porttitor venenatis. Donec a dui et dui fringilla consectetur id nec massa. Aliquam erat volutpat. Sed ut dui ut lacus dictum fermentum vel tincidunt neque. Sed sed lacinia lectus. Duis sit amet sodales felis. Duis nunc eros, mattis at dui ac, convallis semper risus. In adipiscing ultrices tellus, in suscipit massa vehicula eu.'

View File

@@ -14,6 +14,7 @@ import DateFields, { dateDoc } from './collections/Date'
import GroupFields, { groupDoc } from './collections/Group'
import IndexedFields from './collections/Indexed'
import JSONFields, { jsonDoc } from './collections/JSON'
import { LexicalFields, LexicalRichTextDoc } from './collections/Lexical'
import NumberFields, { numberDoc } from './collections/Number'
import PointFields, { pointDoc } from './collections/Point'
import RadioFields, { radiosDoc } from './collections/Radio'
@@ -41,6 +42,7 @@ export default buildConfigWithDefaults({
}),
},
collections: [
LexicalFields,
{
slug: 'users',
auth: true,
@@ -147,6 +149,14 @@ export default buildConfigWithDefaults({
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, formattedTextID),
)
const lexicalRichTextDocWithRelId = JSON.parse(
JSON.stringify(LexicalRichTextDoc)
.replace(/"\{\{ARRAY_DOC_ID\}\}"/g, formattedID)
.replace(/"\{\{UPLOAD_DOC_ID\}\}"/g, formattedJPGID)
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, formattedTextID),
)
await payload.create({ collection: 'lexical-fields', data: lexicalRichTextDocWithRelId })
const richTextDocWithRelationship = { ...richTextDocWithRelId }
await payload.create({ collection: 'rich-text-fields', data: richTextBulletsDocWithRelId })

View File

@@ -505,120 +505,117 @@ describe('fields', () => {
})
describe('row manipulation', () => {
test('should add 2 new rows', async () => {
test('should add, remove and duplicate rows', async () => {
const assertText0 = 'array row 1'
const assertGroupText0 = 'text in group in row 1'
const assertText1 = 'array row 2'
const assertText3 = 'array row 3'
const assertGroupText3 = 'text in group in row 3'
await page.goto(url.create)
// Add 3 rows
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await page.locator('#field-potentiallyEmptyArray__0__text').fill('array row 1')
await page.locator('#field-potentiallyEmptyArray__1__text').fill('array row 2')
await saveDocAndAssert(page)
})
test('should remove 2 new rows', async () => {
await page.goto(url.create)
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await page.locator('#field-potentiallyEmptyArray__0__text').fill('array row 1')
await page.locator('#field-potentiallyEmptyArray__1__text').fill('array row 2')
await page.locator('#potentiallyEmptyArray-row-1 .array-actions__button').click()
// Fill out row 1
await page.locator('#field-potentiallyEmptyArray__0__text').fill(assertText0)
await page
.locator('#potentiallyEmptyArray-row-1 .popup__scroll-container .array-actions__remove')
.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow')
.fill(assertGroupText0)
// Fill out row 2
await page.locator('#field-potentiallyEmptyArray__1__text').fill(assertText1)
// Fill out row 3
await page.locator('#field-potentiallyEmptyArray__2__text').fill(assertText3)
await page
.locator('#field-potentiallyEmptyArray__2__groupInRow__textInGroupInRow')
.fill(assertGroupText3)
// Remove row 1
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
await page
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove')
.click()
// Remove row 2
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
await page
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove')
.click()
const rows = page.locator('#field-potentiallyEmptyArray > .array-field__draggable-rows')
await expect(rows).toBeHidden()
})
test('should remove existing row', async () => {
await page.goto(url.create)
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await page.locator('#field-potentiallyEmptyArray__0__text').fill('array row 1')
// Save document
await saveDocAndAssert(page)
// Scroll to array row (fields are not rendered in DOM until on screen)
await page.locator('#field-potentiallyEmptyArray__0__groupInRow').scrollIntoViewIfNeeded()
// Expect the remaining row to be the third row
const input = page.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow')
await expect(input).toHaveValue(assertGroupText3)
// Duplicate row
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
await page
.locator(
'#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__action.array-actions__remove',
'#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__duplicate',
)
.click()
const rows = page.locator('#field-potentiallyEmptyArray > .array-field__draggable-rows')
// Update duplicated row group field text
await page
.locator('#field-potentiallyEmptyArray__1__groupInRow__textInGroupInRow')
.fill(`${assertGroupText3} duplicate`)
await expect(rows).toBeHidden()
// Save document
await saveDocAndAssert(page)
// Expect the second row to be a duplicate of the remaining row
await expect(
page.locator('#field-potentiallyEmptyArray__1__groupInRow__textInGroupInRow'),
).toHaveValue(`${assertGroupText3} duplicate`)
// Remove row 1
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
await page
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove')
.click()
// Save document
await saveDocAndAssert(page)
// Expect the remaining row to be the copy of the duplicate row
await expect(
page.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow'),
).toHaveValue(`${assertGroupText3} duplicate`)
})
test('should add row after removing existing row', async () => {
await page.goto(url.create)
describe('react hooks', () => {
test('should add 2 new block rows', async () => {
await page.goto(url.create)
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await page.locator('#field-potentiallyEmptyArray__0__text').fill('array row 1')
await page.locator('#field-potentiallyEmptyArray__1__text').fill('array row 2')
await page
.locator('.custom-blocks-field-management')
.getByRole('button', { name: 'Add Block 1' })
.click()
await expect(
page.locator('#field-customBlocks input[name="customBlocks.0.block1Title"]'),
).toHaveValue('Block 1: Prefilled Title')
await saveDocAndAssert(page)
await page
.locator('.custom-blocks-field-management')
.getByRole('button', { name: 'Add Block 2' })
.click()
await expect(
page.locator('#field-customBlocks input[name="customBlocks.1.block2Title"]'),
).toHaveValue('Block 2: Prefilled Title')
await page.locator('#potentiallyEmptyArray-row-1 .array-actions__button').click()
await page
.locator(
'#potentiallyEmptyArray-row-1 .popup__scroll-container .array-actions__action.array-actions__remove',
)
.click()
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
await page.locator('#field-potentiallyEmptyArray__1__text').fill('updated array row 2')
await saveDocAndAssert(page)
const rowsContainer = page.locator(
'#field-potentiallyEmptyArray > .array-field__draggable-rows',
)
const directChildDivCount = await rowsContainer.evaluate((element) => {
const childDivCount = element.querySelectorAll(':scope > div')
return childDivCount.length
await page
.locator('.custom-blocks-field-management')
.getByRole('button', { name: 'Replace Block 2' })
.click()
await expect(
page.locator('#field-customBlocks input[name="customBlocks.1.block1Title"]'),
).toHaveValue('REPLACED BLOCK')
})
expect(directChildDivCount).toBe(2)
})
})
describe('row react hooks', () => {
test('should add 2 new block rows', async () => {
await page.goto(url.create)
await page
.locator('.custom-blocks-field-management')
.getByRole('button', { name: 'Add Block 1' })
.click()
await expect(
page.locator('#field-customBlocks input[name="customBlocks.0.block1Title"]'),
).toHaveValue('Block 1: Prefilled Title')
await page
.locator('.custom-blocks-field-management')
.getByRole('button', { name: 'Add Block 2' })
.click()
await expect(
page.locator('#field-customBlocks input[name="customBlocks.1.block2Title"]'),
).toHaveValue('Block 2: Prefilled Title')
await page
.locator('.custom-blocks-field-management')
.getByRole('button', { name: 'Replace Block 2' })
.click()
await expect(
page.locator('#field-customBlocks input[name="customBlocks.1.block1Title"]'),
).toHaveValue('REPLACED BLOCK')
})
})
})

View File

@@ -1,48 +0,0 @@
import type { Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
import { saveDocAndAssert } from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadTest } from '../helpers/configHelpers'
const { beforeAll, describe } = test
let url: AdminUrlUtil
const slug = 'nested-fields'
let page: Page
describe('Nested Fields', () => {
beforeAll(async ({ browser }) => {
const { serverURL } = await initPayloadTest({
__dirname,
init: {
local: false,
},
})
url = new AdminUrlUtil(serverURL, slug)
const context = await browser.newContext()
page = await context.newPage()
})
test('should save deeply nested fields', async () => {
const assertionValue = 'sample block value'
await page.goto(url.create)
await page.locator('#field-array > button').click()
await page.locator('#field-array__0__group__namedTab__blocks > button').click()
await page.locator('button[title="Block With Field"]').click()
await page.locator('#field-array__0__group__namedTab__blocks__0__text').fill(assertionValue)
await saveDocAndAssert(page)
await expect(page.locator('#field-array__0__group__namedTab__blocks__0__text')).toHaveValue(
assertionValue,
)
})
})