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). 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 #### 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 `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 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 ### Fields

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import type { Connect } from 'payload/database' import type { Connect } from 'payload/database'
import { pushSchema } from 'drizzle-kit/utils'
import { eq, sql } from 'drizzle-orm' import { eq, sql } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/node-postgres' import { drizzle } from 'drizzle-orm/node-postgres'
import { numeric, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core' 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 return
const { pushSchema } = require('drizzle-kit/utils')
// This will prompt if clarifications are needed for Drizzle to push new schema // This will prompt if clarifications are needed for Drizzle to push new schema
const { apply, hasDataLoss, statementsToExecute, warnings } = await pushSchema( const { apply, hasDataLoss, statementsToExecute, warnings } = await pushSchema(
this.schema, this.schema,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,9 @@ import getSiblingData from './getSiblingData'
import reduceFieldsToValues from './reduceFieldsToValues' import reduceFieldsToValues from './reduceFieldsToValues'
import { flattenRows, separateRows } from './rows' 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 { export function fieldReducer(state: Fields, action: FieldAction): Fields {
switch (action.type) { switch (action.type) {
case 'REPLACE_STATE': { case 'REPLACE_STATE': {
@@ -123,7 +126,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
...state[path], ...state[path],
disableFormData: rows.length > 0, disableFormData: rows.length > 0,
rows: rowsMetadata, rows: rowsMetadata,
value: rows, value: rows.length,
}, },
...flattenRows(path, rows), ...flattenRows(path, rows),
} }
@@ -161,10 +164,6 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
const { remainingFields, rows: siblingRows } = separateRows(path, state) const { remainingFields, rows: siblingRows } = separateRows(path, state)
siblingRows.splice(rowIndex, 0, subFieldState) 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 = { const newState: Fields = {
...remainingFields, ...remainingFields,
...flattenRows(path, siblingRows), ...flattenRows(path, siblingRows),
@@ -172,7 +171,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
...state[path], ...state[path],
disableFormData: true, disableFormData: true,
rows: rowsMetadata, rows: rowsMetadata,
value: newValue, value: siblingRows.length,
}, },
} }
@@ -203,10 +202,6 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
// replace form _field state_ // replace form _field state_
siblingRows[rowIndex] = subFieldState siblingRows[rowIndex] = subFieldState
// replace array _value_
const newValue = Array.isArray(state[path]?.value) ? state[path]?.value : []
newValue[rowIndex] = reduceFieldsToValues(subFieldState, true)
const newState: Fields = { const newState: Fields = {
...remainingFields, ...remainingFields,
...flattenRows(path, siblingRows), ...flattenRows(path, siblingRows),
@@ -214,7 +209,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
...state[path], ...state[path],
disableFormData: true, disableFormData: true,
rows: rowsMetadata, rows: rowsMetadata,
value: newValue, value: siblingRows.length,
}, },
} }
@@ -245,7 +240,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
...state[path], ...state[path],
disableFormData: true, disableFormData: true,
rows: rowsMetadata, rows: rowsMetadata,
value: rows, value: rows.length,
}, },
...flattenRows(path, rows), ...flattenRows(path, rows),
} }

View File

@@ -50,12 +50,15 @@ import reduceFieldsToValues from './reduceFieldsToValues'
const baseClass = 'form' const baseClass = 'form'
const Form: React.FC<Props> = (props) => { const Form: React.FC<Props> = (props) => {
const { id, collection, getDocPreferences, global } = useDocumentInfo()
const { const {
action, action,
children, children,
className, className,
disableSuccessStatus, disableSuccessStatus,
disabled, disabled,
fields: fieldsFromProps = collection?.fields || global?.fields,
handleResponse, handleResponse,
initialData, // values only, paths are required as key - form should build initial state as convenience initialData, // values only, paths are required as key - form should build initial state as convenience
initialState, // fully formed initial field state initialState, // fully formed initial field state
@@ -71,7 +74,6 @@ const Form: React.FC<Props> = (props) => {
const { code: locale } = useLocale() const { code: locale } = useLocale()
const { i18n, t } = useTranslation('general') const { i18n, t } = useTranslation('general')
const { refreshCookie, user } = useAuth() const { refreshCookie, user } = useAuth()
const { id, collection, getDocPreferences, global } = useDocumentInfo()
const operation = useOperation() const operation = useOperation()
const config = useConfig() const config = useConfig()
@@ -90,6 +92,10 @@ const Form: React.FC<Props> = (props) => {
if (initialState) initialFieldState = initialState if (initialState) initialFieldState = initialState
const fieldsReducer = useReducer(fieldReducer, {}, () => initialFieldState) 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 const [fields, dispatchFields] = fieldsReducer
contextRef.current.fields = fields contextRef.current.fields = fields
@@ -167,7 +173,13 @@ const Form: React.FC<Props> = (props) => {
let validationResult: boolean | string = true let validationResult: boolean | string = true
if (typeof field.validate === 'function') { 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, id,
config, config,
data, data,
@@ -434,7 +446,7 @@ const Form: React.FC<Props> = (props) => {
const getRowSchemaByPath = React.useCallback( const getRowSchemaByPath = React.useCallback(
({ blockType, path }: { blockType?: string; path: string }) => { ({ blockType, path }: { blockType?: string; path: string }) => {
const rowConfig = traverseRowConfigs({ const rowConfig = traverseRowConfigs({
fieldConfig: collection?.fields || global?.fields, fieldConfig: fieldsFromProps,
path, path,
}) })
const rowFieldConfigs = buildFieldSchemaMap(rowConfig) const rowFieldConfigs = buildFieldSchemaMap(rowConfig)
@@ -442,10 +454,11 @@ const Form: React.FC<Props> = (props) => {
const fieldKey = pathSegments.at(-1) const fieldKey = pathSegments.at(-1)
return rowFieldConfigs.get(blockType ? `${fieldKey}.${blockType}` : fieldKey) 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( const addFieldRow: Context['addFieldRow'] = useCallback(
async ({ data, path, rowIndex }) => { async ({ data, path, rowIndex }) => {
const preferences = await getDocPreferences() const preferences = await getDocPreferences()

View File

@@ -2,7 +2,12 @@ import type React from 'react'
import type { Dispatch } from 'react' import type { Dispatch } from 'react'
import type { User } from '../../../../auth/types' 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 = { export type Row = {
blockType?: string blockType?: string
@@ -41,6 +46,12 @@ export type Props = {
className?: string className?: string
disableSuccessStatus?: boolean disableSuccessStatus?: boolean
disabled?: 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 handleResponse?: (res: Response) => void
initialData?: Data initialData?: Data
initialState?: Fields initialState?: Fields

View File

@@ -17,10 +17,21 @@ const intersectionObserverOptions = {
rootMargin: '1000px', rootMargin: '1000px',
} }
// If you send `fields` through, it will render those fields explicitly /**
// Otherwise, it will reduce your fields using the other provided props * If you send `fields` through, it will render those fields explicitly
// This is so that we can conditionally render fields before reducing them, if desired * Otherwise, it will reduce your fields using the other provided props
// See the sidebar in '../collections/Edit/Default/index.tsx' for an example * 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 RenderFields: React.FC<Props> = (props) => {
const { className, fieldTypes, forceRender, margins } = props const { className, fieldTypes, forceRender, margins } = props

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,8 @@
} }
&__fields { &__fields {
& > .tabs-field { & > .tabs-field,
& > .group-field {
margin-right: calc(var(--base) * -2); margin-right: calc(var(--base) * -2);
} }
} }
@@ -51,7 +52,7 @@
position: sticky; position: sticky;
top: var(--doc-controls-height); top: var(--doc-controls-height);
width: 33.33%; width: 33.33%;
height: 100%; height: calc(100vh - var(--doc-controls-height));
} }
&__sidebar { &__sidebar {
@@ -110,7 +111,8 @@
} }
&__fields { &__fields {
& > .tabs-field { & > .tabs-field,
& > .group-field {
margin-right: calc(var(--gutter-h) * -1); 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-sticky-wrap`}>
<div className={`${baseClass}__sidebar-fields`}> <div className={`${baseClass}__sidebar-fields`}>
<RenderFields <RenderFields
fieldSchema={fields}
fieldTypes={fieldTypes} fieldTypes={fieldTypes}
filter={(field) => field.admin.position === 'sidebar'} fields={sidebarFields}
permissions={permissions.fields} permissions={permissions.fields}
readOnly={!hasSavePermission} readOnly={!hasSavePermission}
/> />

View File

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

View File

@@ -27,7 +27,8 @@
} }
&__fields { &__fields {
& > .tabs-field { & > .tabs-field,
& > .group-field {
margin-right: calc(var(--base) * -2); margin-right: calc(var(--base) * -2);
} }
} }
@@ -55,7 +56,7 @@
position: sticky; position: sticky;
top: var(--doc-controls-height); top: var(--doc-controls-height);
width: 33.33%; width: 33.33%;
height: 100%; height: calc(100vh - var(--doc-controls-height));
} }
&__sidebar { &__sidebar {
@@ -106,7 +107,8 @@
} }
&__fields { &__fields {
& > .tabs-field { & > .tabs-field,
& > .group-field {
margin-right: calc(var(--gutter-h) * -1); 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`}>
<div className={`${baseClass}__sidebar-sticky-wrap`}> <div className={`${baseClass}__sidebar-sticky-wrap`}>
<div className={`${baseClass}__sidebar-fields`}> <div className={`${baseClass}__sidebar-fields`}>
<RenderFields fieldTypes={fieldTypes} fields={sidebarFields} /> <RenderFields
fieldTypes={fieldTypes}
fields={sidebarFields}
permissions={permissions.fields}
readOnly={!hasSavePermission}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -382,7 +382,7 @@ export const relationship: Validate<unknown, unknown, RelationshipField> = async
}) })
if (invalidRelationships.length > 0) { 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) => { .map((err, invalid) => {
return `${err} ${JSON.stringify(invalid)}` return `${err} ${JSON.stringify(invalid)}`
}) })

View File

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

View File

@@ -16,17 +16,38 @@ export const RichTextCell: React.FC<
const [preview, setPreview] = React.useState('Loading...') const [preview, setPreview] = React.useState('Loading...')
useEffect(() => { useEffect(() => {
if (data == null) { let dataToUse = data
if (dataToUse == null) {
setPreview('') setPreview('')
return 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 // initialize headless editor
const headlessEditor = createHeadlessEditor({ const headlessEditor = createHeadlessEditor({
namespace: editorConfig.lexical.namespace, namespace: editorConfig.lexical.namespace,
nodes: getEnabledNodes({ editorConfig }), nodes: getEnabledNodes({ editorConfig }),
theme: editorConfig.lexical.theme, theme: editorConfig.lexical.theme,
}) })
headlessEditor.setEditorState(headlessEditor.parseEditorState(data)) headlessEditor.setEditorState(headlessEditor.parseEditorState(dataToUse))
const textContent = const textContent =
headlessEditor.getEditorState().read(() => { headlessEditor.getEditorState().read(() => {

View File

@@ -89,9 +89,16 @@ const RichText: React.FC<FieldProps> = (props) => {
fieldProps={props} fieldProps={props}
initialState={initialValue} initialState={initialValue}
onChange={(editorState, editor, tags) => { 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} readOnly={readOnly}
setValue={setValue} setValue={setValue}

View File

@@ -1,3 +1,5 @@
import type { Block } from 'payload/types'
import { sanitizeFields } from 'payload/config' import { sanitizeFields } from 'payload/config'
import type { BlocksFeatureProps } from '.' import type { BlocksFeatureProps } from '.'
@@ -20,40 +22,42 @@ export const blockAfterReadPromiseHOC = (
showHiddenFields, showHiddenFields,
siblingDoc, siblingDoc,
}) => { }) => {
const blocks: Block[] = props.blocks
const blockFieldData = node.fields.data
const promises: Promise<void>[] = [] 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 // 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 payloadConfig = req.payload.config
const validRelationships = payloadConfig.collections.map((c) => c.slug) || [] const validRelationships = payloadConfig.collections.map((c) => c.slug) || []
props.blocks = props.blocks.map((block) => { blocks.forEach((block) => {
const unsanitizedBlock = { ...block } block.fields = sanitizeFields({
unsanitizedBlock.fields = sanitizeFields({
config: payloadConfig, config: payloadConfig,
fields: block.fields, fields: block.fields,
validRelationships, validRelationships,
}) })
return unsanitizedBlock
}) })
if (Array.isArray(props.blocks)) { // find block used in this node
props.blocks.forEach((block) => { const block = props.blocks.find((block) => block.slug === blockFieldData.blockType)
if (block?.fields) { if (!block || !block?.fields?.length || !blockFieldData) {
recurseNestedFields({ return promises
afterReadPromises,
currentDepth,
data: node.fields.data || {},
depth,
fields: block.fields,
overrideAccess,
promises,
req,
showHiddenFields,
siblingDoc,
})
}
})
} }
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 return promises
} }

View File

@@ -24,6 +24,11 @@ type Props = {
nodeKey: string 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) => { export const BlockContent: React.FC<Props> = (props) => {
const { baseClass, block, field, fields, nodeKey } = props const { baseClass, block, field, fields, nodeKey } = props
const { i18n } = useTranslation() const { i18n } = useTranslation()

View File

@@ -1,14 +1,20 @@
import { type ElementFormatType } from 'lexical' import { type ElementFormatType } from 'lexical'
import { Form, buildInitialState, useFormSubmitted } from 'payload/components/forms' 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' import { type BlockFields } from '../nodes/BlocksNode'
const baseClass = 'lexical-block' const baseClass = 'lexical-block'
import type { Data } from 'payload/types' 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 { sanitizeFields } from 'payload/config'
import { useTranslation } from 'react-i18next'
import type { BlocksFeatureProps } from '..' import type { BlocksFeatureProps } from '..'
@@ -43,13 +49,49 @@ export const BlockComponent: React.FC<Props> = (props) => {
validRelationships, 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 // Memoized Form JSX
const formContent = useMemo(() => { const formContent = useMemo(() => {
return ( return (
block && ( block &&
<Form initialState={initialDataRef?.current} submitted={submitted}> initialState && (
<Form fields={block.fields} initialState={initialState} submitted={submitted}>
<BlockContent <BlockContent
baseClass={baseClass} baseClass={baseClass}
block={block} block={block}
@@ -60,7 +102,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
</Form> </Form>
) )
) )
}, [block, field, nodeKey, submitted]) }, [block, field, nodeKey, submitted, initialState])
return <div className={baseClass}>{formContent}</div> return <div className={baseClass}>{formContent}</div>
} }

View File

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

View File

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

View File

@@ -51,7 +51,7 @@ export const uploadAfterReadPromiseHOC = (
promises, promises,
req, req,
showHiddenFields, 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?: { floatingSelectToolbar?: {
sections: FloatingToolbarSection[] sections: FloatingToolbarSection[]
} }
hooks?: {
load?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
save?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
}
markdownTransformers?: Transformer[] markdownTransformers?: Transformer[]
nodes?: Array<{ nodes?: Array<{
afterReadPromises?: Array<AfterReadPromise> afterReadPromises?: Array<AfterReadPromise>
@@ -123,6 +135,22 @@ export type SanitizedFeatures = Required<
floatingSelectToolbar: { floatingSelectToolbar: {
sections: FloatingToolbarSection[] sections: FloatingToolbarSection[]
} }
hooks: {
load: Array<
({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
>
save: Array<
({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
>
}
plugins?: Array< 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 // 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 value: SerializedEditorState
} }
export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => { 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 ( if (
(value && Array.isArray(value) && !('root' in value)) || (value && Array.isArray(value) && !('root' in value)) ||

View File

@@ -10,6 +10,10 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
floatingSelectToolbar: { floatingSelectToolbar: {
sections: [], sections: [],
}, },
hooks: {
load: [],
save: [],
},
markdownTransformers: [], markdownTransformers: [],
nodes: [], nodes: [],
plugins: [], plugins: [],
@@ -21,6 +25,15 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
} }
features.forEach((feature) => { 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) { if (feature.nodes?.length) {
sanitized.nodes = sanitized.nodes.concat(feature.nodes) sanitized.nodes = sanitized.nodes.concat(feature.nodes)
feature.nodes.forEach((node) => { 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 { CheckListFeature } from './field/features/lists/CheckList'
export { OrderedListFeature } from './field/features/lists/OrderedList' export { OrderedListFeature } from './field/features/lists/OrderedList'
export { UnoderedListFeature } from './field/features/lists/UnorderedList' export { UnoderedListFeature } from './field/features/lists/UnorderedList'
export { SlateToLexicalFeature } from './field/features/migrations/SlateToLexical'
export type { export type {
AfterReadPromise, AfterReadPromise,
Feature, Feature,
@@ -201,6 +202,20 @@ export { isHTMLElement } from './field/lexical/utils/guard'
export { invariant } from './field/lexical/utils/invariant' export { invariant } from './field/lexical/utils/invariant'
export { joinClasses } from './field/lexical/utils/joinClasses' export { joinClasses } from './field/lexical/utils/joinClasses'
export { createBlockNode } from './field/lexical/utils/markdown/createBlockNode' 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 { Point, isPoint } from './field/lexical/utils/point'
export { Rect } from './field/lexical/utils/rect' export { Rect } from './field/lexical/utils/rect'
export { setFloatingElemPosition } from './field/lexical/utils/setFloatingElemPosition' export { setFloatingElemPosition } from './field/lexical/utils/setFloatingElemPosition'

View File

@@ -173,7 +173,7 @@ export const recurseNestedFields = ({
promises, promises,
req, req,
showHiddenFields, 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, promises,
req, req,
showHiddenFields, 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') { 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 const editor: RichTextAdapter = field?.editor
if (editor?.afterReadPromise) { 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({ recurseRichText({
afterReadPromises, afterReadPromises,
children: node.children as SerializedLexicalNode[], children: node.children as SerializedLexicalNode[],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
'use client'
import React from 'react' import React from 'react'
import { useSlate } from 'slate-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 { useStepNav } from '../../../../../packages/payload/src/admin/components/elements/StepNav'
import { useConfig } from '../../../../../packages/payload/src/admin/components/utilities/Config' import { useConfig } from '../../../../../packages/payload/src/admin/components/utilities/Config'
const CustomDefaultView: AdminViewComponent = ({ const CustomDefaultEditView: AdminViewComponent = ({
canAccessAdmin, canAccessAdmin,
// collection, // collection,
// global, // 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 path from 'path'
import { mapAsync } from '../../packages/payload/src/utilities/mapAsync' import { mapAsync } from '../../packages/payload/src/utilities/mapAsync'
import { slateEditor } from '../../packages/richtext-slate/src'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults' import { buildConfigWithDefaults } from '../buildConfigWithDefaults'
import { devUser } from '../credentials' 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 AfterDashboard from './components/AfterDashboard'
import AfterNavLinks from './components/AfterNavLinks' import AfterNavLinks from './components/AfterNavLinks'
import BeforeLogin from './components/BeforeLogin' 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 Logout from './components/Logout'
import CustomDefaultView from './components/views/CustomDefault' 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 CustomMinimalRoute from './components/views/CustomMinimal'
import CustomVersionsView from './components/views/CustomVersions' import { CustomGlobalViews1 } from './globals/CustomViews1'
import CustomView from './components/views/CustomView' import { CustomGlobalViews2 } from './globals/CustomViews2'
import { globalSlug, postsSlug, slugPluralLabel, slugSingularLabel } from './shared' 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 { export interface Post {
createdAt: Date createdAt: Date
@@ -66,321 +74,24 @@ export default buildConfigWithDefaults({
locales: ['en', 'es'], locales: ['en', 'es'],
}, },
collections: [ collections: [
{ Posts,
slug: 'users', Users,
auth: true, CollectionHidden,
admin: { CustomViews1,
useAsTitle: 'email', CustomViews2,
}, CollectionGroup1A,
fields: [], CollectionGroup1B,
}, CollectionGroup2A,
{ CollectionGroup2B,
slug: 'hidden-collection', Geo,
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',
},
],
},
], ],
globals: [ globals: [
{ GlobalHidden,
slug: 'hidden-global', Global,
admin: { CustomGlobalViews1,
hidden: () => true, CustomGlobalViews2,
}, GlobalGroup1A,
fields: [ GlobalGroup1B,
{
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',
},
],
},
], ],
onInit: async (payload) => { onInit: async (payload) => {
await payload.create({ await payload.create({

View File

@@ -17,7 +17,13 @@ import {
} from '../helpers' } from '../helpers'
import { AdminUrlUtil } from '../helpers/adminUrlUtil' import { AdminUrlUtil } from '../helpers/adminUrlUtil'
import { initPayloadE2E } from '../helpers/configHelpers' 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 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', () => { describe('doc titles', () => {
test('collection - should render fallback titles when creating new', async () => { test('collection - should render fallback titles when creating new', async () => {
await page.goto(url.create) await page.goto(url.create)
@@ -292,10 +330,12 @@ describe('admin', () => {
await page.locator('input#select-all').check() await page.locator('input#select-all').check()
await page.locator('.edit-many__toggle').click() await page.locator('.edit-many__toggle').click()
await page.locator('.field-select .rs__control').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() await titleOption.click()
const titleInput = page.locator('#field-title') 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 postsSlug = 'posts'
export const group1Collection1Slug = 'group-one-collection-ones'
export const group1Collection2Slug = 'group-one-collection-twos'
export const slugSingularLabel = 'Post' export const slugSingularLabel = 'Post'
export const slugPluralLabel = 'Posts' export const slugPluralLabel = 'Posts'
export const globalSlug = 'global' 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 = () => { export const AddCustomBlocks: React.FC = () => {
const { addFieldRow, replaceFieldRow } = useForm() const { addFieldRow, replaceFieldRow } = useForm()
const { value } = useField({ path: 'customBlocks' }) const { value } = useField<number>({ path: 'customBlocks' })
return ( return (
<div className={baseClass}> <div className={baseClass}>
@@ -47,12 +47,12 @@ export const AddCustomBlocks: React.FC = () => {
replaceFieldRow({ replaceFieldRow({
data: { block1Title: 'REPLACED BLOCK', blockType: 'block-1' }, data: { block1Title: 'REPLACED BLOCK', blockType: 'block-1' },
path: 'customBlocks', path: 'customBlocks',
rowIndex: (Array.isArray(value) ? value.length : 0) - 1, rowIndex: value - 1,
}) })
} }
type="button" type="button"
> >
Replace Block {Array.isArray(value) ? value.length : 0} Replace Block {value}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -83,6 +83,16 @@ const ArrayFields: CollectionConfig = {
type: 'text', type: 'text',
name: '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 GroupFields, { groupDoc } from './collections/Group'
import IndexedFields from './collections/Indexed' import IndexedFields from './collections/Indexed'
import JSONFields, { jsonDoc } from './collections/JSON' import JSONFields, { jsonDoc } from './collections/JSON'
import { LexicalFields, LexicalRichTextDoc } from './collections/Lexical'
import NumberFields, { numberDoc } from './collections/Number' import NumberFields, { numberDoc } from './collections/Number'
import PointFields, { pointDoc } from './collections/Point' import PointFields, { pointDoc } from './collections/Point'
import RadioFields, { radiosDoc } from './collections/Radio' import RadioFields, { radiosDoc } from './collections/Radio'
@@ -41,6 +42,7 @@ export default buildConfigWithDefaults({
}), }),
}, },
collections: [ collections: [
LexicalFields,
{ {
slug: 'users', slug: 'users',
auth: true, auth: true,
@@ -147,6 +149,14 @@ export default buildConfigWithDefaults({
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, formattedTextID), .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 } const richTextDocWithRelationship = { ...richTextDocWithRelId }
await payload.create({ collection: 'rich-text-fields', data: richTextBulletsDocWithRelId }) await payload.create({ collection: 'rich-text-fields', data: richTextBulletsDocWithRelId })

View File

@@ -505,120 +505,117 @@ describe('fields', () => {
}) })
describe('row manipulation', () => { 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) 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 > .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 > .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 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() .click()
// Remove row 2
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click() await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
await page await page
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove') .locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove')
.click() .click()
const rows = page.locator('#field-potentiallyEmptyArray > .array-field__draggable-rows') // Save document
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')
await saveDocAndAssert(page) 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 .array-actions__button').click()
await page await page
.locator( .locator(
'#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__action.array-actions__remove', '#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__duplicate',
) )
.click() .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 () => { describe('react hooks', () => {
await page.goto(url.create) 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
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click() .locator('.custom-blocks-field-management')
await page.locator('#field-potentiallyEmptyArray__0__text').fill('array row 1') .getByRole('button', { name: 'Add Block 1' })
await page.locator('#field-potentiallyEmptyArray__1__text').fill('array row 2') .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
await page .locator('.custom-blocks-field-management')
.locator( .getByRole('button', { name: 'Replace Block 2' })
'#potentiallyEmptyArray-row-1 .popup__scroll-container .array-actions__action.array-actions__remove', .click()
) await expect(
.click() page.locator('#field-customBlocks input[name="customBlocks.1.block1Title"]'),
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click() ).toHaveValue('REPLACED BLOCK')
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
}) })
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,
)
})
})