Compare commits
28 Commits
db-postgre
...
db-postgre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7caa098023 | ||
|
|
fd54c40400 | ||
|
|
e180131314 | ||
|
|
5902d4542b | ||
|
|
6bc282444e | ||
|
|
4dc6c09347 | ||
|
|
03b9ab0054 | ||
|
|
3c3c93f483 | ||
|
|
5dbfb1a335 | ||
|
|
d411874589 | ||
|
|
8358e2f2d2 | ||
|
|
012b8e6f90 | ||
|
|
fcd4c8d830 | ||
|
|
81ec435363 | ||
|
|
e116fcfbf5 | ||
|
|
c47632dc1d | ||
|
|
0dab68b336 | ||
|
|
483f93bfcf | ||
|
|
4bd01df411 | ||
|
|
c956a85252 | ||
|
|
beed83b231 | ||
|
|
3b1bdcbe41 | ||
|
|
d3d0971275 | ||
|
|
1a99d66cd0 | ||
|
|
52c4a63bf1 | ||
|
|
3446d28602 | ||
|
|
2eb18771a1 | ||
|
|
f6fd5d6742 |
@@ -129,7 +129,7 @@ To add a _new_ view to the Admin Panel, simply add another key to the `views` ob
|
||||
}
|
||||
```
|
||||
|
||||
_For more examples regarding how to customize components, look at the following [examples](https://github.com/payloadcms/payload/tree/master/test/admin/components)._
|
||||
_For more examples regarding how to customize components, look at the following [examples](https://github.com/payloadcms/payload/tree/main/test/admin/components)._
|
||||
|
||||
For help on how to build your own custom view components, see [building a custom view component](#building-a-custom-view-component).
|
||||
|
||||
@@ -399,12 +399,12 @@ Your custom view components will be given all the props that a React Router `<Ro
|
||||
|
||||
#### Example
|
||||
|
||||
You can find examples of custom views in the [Payload source code `/test/admin/components/views` folder](https://github.com/payloadcms/payload/tree/master/test/admin/components/views). There, you'll find two custom views:
|
||||
You can find examples of custom views in the [Payload source code `/test/admin/components/views` folder](https://github.com/payloadcms/payload/tree/main/test/admin/components/views). There, you'll find two custom views:
|
||||
|
||||
1. A custom view that uses the `DefaultTemplate`, which is the built-in Payload template that displays the sidebar and "eyebrow nav"
|
||||
1. A custom view that uses the `MinimalTemplate` - which is just a centered template used for things like logging in or out
|
||||
|
||||
To see how to pass in your custom views to create custom views of your own, take a look at the `admin.components.views` property of the [Payload test admin config](https://github.com/payloadcms/payload/blob/master/test/admin/config.ts).
|
||||
To see how to pass in your custom views to create custom views of your own, take a look at the `admin.components.views` property of the [Payload test admin config](https://github.com/payloadcms/payload/blob/main/test/admin/config.ts).
|
||||
|
||||
### Fields
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
"lint-staged": "lint-staged",
|
||||
"pretest": "pnpm build",
|
||||
"reinstall": "pnpm clean:unix && pnpm install",
|
||||
"list:packages": "./scripts/list_published_packages.sh beta",
|
||||
"script:release:beta": "./scripts/release_beta.sh",
|
||||
"script:list-packages": "tsx ./scripts/list-packages.ts",
|
||||
"script:release": "tsx ./scripts/release.ts",
|
||||
"test": "pnpm test:int && pnpm test:components && pnpm test:e2e",
|
||||
"test:components": "cross-env jest --config=jest.components.config.js",
|
||||
"test:e2e": "npx playwright install --with-deps && ts-node -T ./test/runE2E.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-postgres",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"description": "The officially supported Postgres database adapter for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Connect } from 'payload/database'
|
||||
|
||||
import { pushSchema } from 'drizzle-kit/utils'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||
import { numeric, pgTable, timestamp, varchar } from 'drizzle-orm/pg-core'
|
||||
@@ -40,6 +39,8 @@ export const connect: Connect = async function connect(this: PostgresAdapter, pa
|
||||
)
|
||||
return
|
||||
|
||||
const { pushSchema } = require('drizzle-kit/utils')
|
||||
|
||||
// This will prompt if clarifications are needed for Drizzle to push new schema
|
||||
const { apply, hasDataLoss, statementsToExecute, warnings } = await pushSchema(
|
||||
this.schema,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { DrizzleSnapshotJSON } from 'drizzle-kit/utils'
|
||||
import type { CreateMigration } from 'payload/database'
|
||||
|
||||
import { generateDrizzleJson, generateMigration } from 'drizzle-kit/utils'
|
||||
import fs from 'fs'
|
||||
import prompts from 'prompts'
|
||||
|
||||
@@ -61,6 +60,8 @@ export const createMigration: CreateMigration = async function createMigration(
|
||||
fs.mkdirSync(dir)
|
||||
}
|
||||
|
||||
const { generateDrizzleJson, generateMigration } = require('drizzle-kit/utils')
|
||||
|
||||
const [yyymmdd, hhmmss] = new Date().toISOString().split('T')
|
||||
const formattedDate = yyymmdd.replace(/\D/g, '')
|
||||
const formattedTime = hhmmss.split('.')[0].replace(/\D/g, '')
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import type { Payload } from 'payload'
|
||||
import type { Migration } from 'payload/database'
|
||||
|
||||
import { generateDrizzleJson } from 'drizzle-kit/utils'
|
||||
import { readMigrationFiles } from 'payload/database'
|
||||
import { DatabaseError } from 'pg'
|
||||
import prompts from 'prompts'
|
||||
|
||||
import type { PostgresAdapter } from './types'
|
||||
@@ -78,6 +76,8 @@ export async function migrate(this: PostgresAdapter): Promise<void> {
|
||||
}
|
||||
|
||||
async function runMigrationFile(payload: Payload, migration: Migration, batch: number) {
|
||||
const { generateDrizzleJson } = require('drizzle-kit/utils')
|
||||
|
||||
const start = Date.now()
|
||||
|
||||
payload.logger.info({ msg: `Migrating: ${migration.name}` })
|
||||
|
||||
@@ -6,11 +6,12 @@
|
||||
|
||||
// drizzle-kit@utils
|
||||
|
||||
import { generateDrizzleJson, generateMigration, pushSchema } from 'drizzle-kit/utils'
|
||||
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||
import { Pool } from 'pg'
|
||||
|
||||
async function generateUsage() {
|
||||
const { generateDrizzleJson, generateMigration } = require('drizzle-kit/utils')
|
||||
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const schema = await import('./data/users')
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
@@ -25,6 +26,8 @@ async function generateUsage() {
|
||||
}
|
||||
|
||||
async function pushUsage() {
|
||||
const { pushSchema } = require('drizzle-kit/utils')
|
||||
|
||||
// @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue
|
||||
const schemaAfter = await import('./data/users-after')
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview-react",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "The official live preview React SDK for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "The official live preview JavaScript SDK for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -208,8 +208,7 @@
|
||||
"webpack": "^5.78.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14",
|
||||
"pnpm": ">=8"
|
||||
"node": ">=14"
|
||||
},
|
||||
"files": [
|
||||
"bin.js",
|
||||
|
||||
@@ -55,24 +55,6 @@ export const DocumentControls: React.FC<{
|
||||
|
||||
const { i18n, t } = useTranslation('general')
|
||||
|
||||
let showPreviewButton = false
|
||||
|
||||
if (collection) {
|
||||
showPreviewButton =
|
||||
isEditing &&
|
||||
collection?.admin?.preview &&
|
||||
collection?.versions?.drafts &&
|
||||
!collection?.versions?.drafts?.autosave
|
||||
}
|
||||
|
||||
if (global) {
|
||||
showPreviewButton =
|
||||
isEditing &&
|
||||
global?.admin?.preview &&
|
||||
global?.versions?.drafts &&
|
||||
!global?.versions?.drafts?.autosave
|
||||
}
|
||||
|
||||
const showDotMenu = Boolean(collection && id && !disableActions)
|
||||
|
||||
return (
|
||||
@@ -165,7 +147,7 @@ export const DocumentControls: React.FC<{
|
||||
</div>
|
||||
<div className={`${baseClass}__controls-wrapper`}>
|
||||
<div className={`${baseClass}__controls`}>
|
||||
{showPreviewButton && (
|
||||
{(collection?.admin?.preview || global?.admin?.preview) && (
|
||||
<PreviewButton
|
||||
CustomComponent={
|
||||
collection?.admin?.components?.edit?.PreviewButton ||
|
||||
|
||||
@@ -51,6 +51,7 @@ const Duplicate: React.FC<Props> = ({ id, collection, slug }) => {
|
||||
},
|
||||
params: {
|
||||
depth: 0,
|
||||
draft: true,
|
||||
locale,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -61,6 +61,7 @@ export const addFieldStatePromise = async ({
|
||||
user,
|
||||
value: data?.[field.name],
|
||||
})
|
||||
|
||||
if (data?.[field.name]) {
|
||||
data[field.name] = valueWithDefault
|
||||
}
|
||||
@@ -145,8 +146,8 @@ export const addFieldStatePromise = async ({
|
||||
fieldState.value = null
|
||||
fieldState.initialValue = null
|
||||
} else {
|
||||
fieldState.value = arrayValue
|
||||
fieldState.initialValue = arrayValue
|
||||
fieldState.value = arrayValue.length
|
||||
fieldState.initialValue = arrayValue.length
|
||||
|
||||
if (arrayValue.length > 0) {
|
||||
fieldState.disableFormData = true
|
||||
@@ -236,8 +237,8 @@ export const addFieldStatePromise = async ({
|
||||
fieldState.value = null
|
||||
fieldState.initialValue = null
|
||||
} else {
|
||||
fieldState.value = blocksValue
|
||||
fieldState.initialValue = blocksValue
|
||||
fieldState.value = blocksValue.length
|
||||
fieldState.initialValue = blocksValue.length
|
||||
|
||||
if (blocksValue.length > 0) {
|
||||
fieldState.disableFormData = true
|
||||
|
||||
@@ -8,6 +8,9 @@ import getSiblingData from './getSiblingData'
|
||||
import reduceFieldsToValues from './reduceFieldsToValues'
|
||||
import { flattenRows, separateRows } from './rows'
|
||||
|
||||
/**
|
||||
* Reducer which modifies the form field state (all the current data of the fields in the form). When called using dispatch, it will return a new state object.
|
||||
*/
|
||||
export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
switch (action.type) {
|
||||
case 'REPLACE_STATE': {
|
||||
@@ -123,7 +126,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
...state[path],
|
||||
disableFormData: rows.length > 0,
|
||||
rows: rowsMetadata,
|
||||
value: rows,
|
||||
value: rows.length,
|
||||
},
|
||||
...flattenRows(path, rows),
|
||||
}
|
||||
@@ -161,10 +164,6 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
const { remainingFields, rows: siblingRows } = separateRows(path, state)
|
||||
siblingRows.splice(rowIndex, 0, subFieldState)
|
||||
|
||||
// add new row to array _value_
|
||||
const currentValue = (Array.isArray(state[path]?.value) ? state[path]?.value : []) as Fields[]
|
||||
const newValue = currentValue.splice(rowIndex, 0, reduceFieldsToValues(subFieldState, true))
|
||||
|
||||
const newState: Fields = {
|
||||
...remainingFields,
|
||||
...flattenRows(path, siblingRows),
|
||||
@@ -172,7 +171,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
...state[path],
|
||||
disableFormData: true,
|
||||
rows: rowsMetadata,
|
||||
value: newValue,
|
||||
value: siblingRows.length,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -203,10 +202,6 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
// replace form _field state_
|
||||
siblingRows[rowIndex] = subFieldState
|
||||
|
||||
// replace array _value_
|
||||
const newValue = Array.isArray(state[path]?.value) ? state[path]?.value : []
|
||||
newValue[rowIndex] = reduceFieldsToValues(subFieldState, true)
|
||||
|
||||
const newState: Fields = {
|
||||
...remainingFields,
|
||||
...flattenRows(path, siblingRows),
|
||||
@@ -214,7 +209,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
...state[path],
|
||||
disableFormData: true,
|
||||
rows: rowsMetadata,
|
||||
value: newValue,
|
||||
value: siblingRows.length,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -245,7 +240,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
...state[path],
|
||||
disableFormData: true,
|
||||
rows: rowsMetadata,
|
||||
value: rows,
|
||||
value: rows.length,
|
||||
},
|
||||
...flattenRows(path, rows),
|
||||
}
|
||||
|
||||
@@ -50,12 +50,15 @@ import reduceFieldsToValues from './reduceFieldsToValues'
|
||||
const baseClass = 'form'
|
||||
|
||||
const Form: React.FC<Props> = (props) => {
|
||||
const { id, collection, getDocPreferences, global } = useDocumentInfo()
|
||||
|
||||
const {
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
disableSuccessStatus,
|
||||
disabled,
|
||||
fields: fieldsFromProps = collection?.fields || global?.fields,
|
||||
handleResponse,
|
||||
initialData, // values only, paths are required as key - form should build initial state as convenience
|
||||
initialState, // fully formed initial field state
|
||||
@@ -71,7 +74,6 @@ const Form: React.FC<Props> = (props) => {
|
||||
const { code: locale } = useLocale()
|
||||
const { i18n, t } = useTranslation('general')
|
||||
const { refreshCookie, user } = useAuth()
|
||||
const { id, collection, getDocPreferences, global } = useDocumentInfo()
|
||||
const operation = useOperation()
|
||||
|
||||
const config = useConfig()
|
||||
@@ -90,6 +92,10 @@ const Form: React.FC<Props> = (props) => {
|
||||
if (initialState) initialFieldState = initialState
|
||||
|
||||
const fieldsReducer = useReducer(fieldReducer, {}, () => initialFieldState)
|
||||
/**
|
||||
* `fields` is the current, up-to-date state/data of all fields in the form. It can be modified by using dispatchFields,
|
||||
* which calls the fieldReducer, which then updates the state.
|
||||
*/
|
||||
const [fields, dispatchFields] = fieldsReducer
|
||||
|
||||
contextRef.current.fields = fields
|
||||
@@ -167,7 +173,13 @@ const Form: React.FC<Props> = (props) => {
|
||||
let validationResult: boolean | string = true
|
||||
|
||||
if (typeof field.validate === 'function') {
|
||||
validationResult = await field.validate(field.value, {
|
||||
let valueToValidate = field.value
|
||||
|
||||
if (field?.rows && Array.isArray(field.rows)) {
|
||||
valueToValidate = contextRef.current.getDataByPath(path)
|
||||
}
|
||||
|
||||
validationResult = await field.validate(valueToValidate, {
|
||||
id,
|
||||
config,
|
||||
data,
|
||||
@@ -434,7 +446,7 @@ const Form: React.FC<Props> = (props) => {
|
||||
const getRowSchemaByPath = React.useCallback(
|
||||
({ blockType, path }: { blockType?: string; path: string }) => {
|
||||
const rowConfig = traverseRowConfigs({
|
||||
fieldConfig: collection?.fields || global?.fields,
|
||||
fieldConfig: fieldsFromProps,
|
||||
path,
|
||||
})
|
||||
const rowFieldConfigs = buildFieldSchemaMap(rowConfig)
|
||||
@@ -442,10 +454,11 @@ const Form: React.FC<Props> = (props) => {
|
||||
const fieldKey = pathSegments.at(-1)
|
||||
return rowFieldConfigs.get(blockType ? `${fieldKey}.${blockType}` : fieldKey)
|
||||
},
|
||||
[traverseRowConfigs, collection?.fields, global?.fields],
|
||||
[traverseRowConfigs, fieldsFromProps],
|
||||
)
|
||||
|
||||
// Array/Block row manipulation
|
||||
// Array/Block row manipulation. This is called when, for example, you add a new block to a blocks field.
|
||||
// The block data is saved in the rows property of the state, which is modified updated here.
|
||||
const addFieldRow: Context['addFieldRow'] = useCallback(
|
||||
async ({ data, path, rowIndex }) => {
|
||||
const preferences = await getDocPreferences()
|
||||
|
||||
@@ -2,7 +2,12 @@ import type React from 'react'
|
||||
import type { Dispatch } from 'react'
|
||||
|
||||
import type { User } from '../../../../auth/types'
|
||||
import type { Condition, Field as FieldConfig, Validate } from '../../../../fields/config/types'
|
||||
import type {
|
||||
Condition,
|
||||
Field,
|
||||
Field as FieldConfig,
|
||||
Validate,
|
||||
} from '../../../../fields/config/types'
|
||||
|
||||
export type Row = {
|
||||
blockType?: string
|
||||
@@ -41,6 +46,12 @@ export type Props = {
|
||||
className?: string
|
||||
disableSuccessStatus?: boolean
|
||||
disabled?: boolean
|
||||
/**
|
||||
* By default, the form will get the field schema (not data) from the current document. If you pass this in, you can override that behavior.
|
||||
* This is very useful for sub-forms, where the form's field schema is not necessarily the field schema of the current document (e.g. for the Blocks
|
||||
* feature of the Lexical Rich Text field)
|
||||
*/
|
||||
fields?: Field[]
|
||||
handleResponse?: (res: Response) => void
|
||||
initialData?: Data
|
||||
initialState?: Fields
|
||||
|
||||
@@ -17,10 +17,21 @@ const intersectionObserverOptions = {
|
||||
rootMargin: '1000px',
|
||||
}
|
||||
|
||||
// If you send `fields` through, it will render those fields explicitly
|
||||
// Otherwise, it will reduce your fields using the other provided props
|
||||
// This is so that we can conditionally render fields before reducing them, if desired
|
||||
// See the sidebar in '../collections/Edit/Default/index.tsx' for an example
|
||||
/**
|
||||
* If you send `fields` through, it will render those fields explicitly
|
||||
* Otherwise, it will reduce your fields using the other provided props
|
||||
* This is so that we can conditionally render fields before reducing them, if desired
|
||||
* See the sidebar in '../collections/Edit/Default/index.tsx' for an example
|
||||
*
|
||||
* The state/data for the fields it renders is not managed by this component. Instead, every component it renders has
|
||||
* their own handling of their own value, usually through the useField hook. This hook will get the field's value
|
||||
* from the Form the field is in, using the field's path.
|
||||
*
|
||||
* Thus, if you would like to set the value of a field you render here, you must do so in the Form that contains the field, or in the
|
||||
* Field component itself.
|
||||
*
|
||||
* All this component does is render the field's Field Components, and pass them the props they need to function.
|
||||
**/
|
||||
const RenderFields: React.FC<Props> = (props) => {
|
||||
const { className, fieldTypes, forceRender, margins } = props
|
||||
|
||||
|
||||
@@ -6,21 +6,23 @@ import type { ReducedField } from './filterFields'
|
||||
export type Props = {
|
||||
className?: string
|
||||
fieldTypes: FieldTypes
|
||||
margins?: 'small' | false
|
||||
forceRender?: boolean
|
||||
margins?: 'small' | false
|
||||
permissions?:
|
||||
| {
|
||||
[field: string]: FieldPermissions
|
||||
}
|
||||
| FieldPermissions
|
||||
readOnly?: boolean
|
||||
} & (
|
||||
| {
|
||||
// Fields to be filtered by the component
|
||||
fieldSchema: FieldWithPath[]
|
||||
filter?: (field: Field) => boolean
|
||||
indexPath?: string
|
||||
permissions?:
|
||||
| {
|
||||
[field: string]: FieldPermissions
|
||||
}
|
||||
| FieldPermissions
|
||||
readOnly?: boolean
|
||||
}
|
||||
| {
|
||||
// Pre-filtered fields to be simply rendered
|
||||
fields: ReducedField[]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -91,7 +91,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
showError,
|
||||
valid,
|
||||
value,
|
||||
} = useField<[]>({
|
||||
} = useField<number>({
|
||||
condition,
|
||||
hasRows: true,
|
||||
path,
|
||||
@@ -123,8 +123,8 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
)
|
||||
|
||||
const removeRow = useCallback(
|
||||
async (rowIndex: number) => {
|
||||
await removeFieldRow({ path, rowIndex })
|
||||
(rowIndex: number) => {
|
||||
removeFieldRow({ path, rowIndex })
|
||||
setModified(true)
|
||||
},
|
||||
[removeFieldRow, path, setModified],
|
||||
@@ -278,7 +278,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
icon="plus"
|
||||
iconPosition="left"
|
||||
iconStyle="with-border"
|
||||
onClick={() => addRow(value?.length || 0)}
|
||||
onClick={() => addRow(value || 0)}
|
||||
>
|
||||
{t('addLabel', { label: getTranslation(labels.singular, i18n) })}
|
||||
</Button>
|
||||
|
||||
@@ -90,7 +90,7 @@ const BlocksField: React.FC<Props> = (props) => {
|
||||
showError,
|
||||
valid,
|
||||
value,
|
||||
} = useField<[]>({
|
||||
} = useField<number>({
|
||||
condition,
|
||||
hasRows: true,
|
||||
path,
|
||||
@@ -128,8 +128,8 @@ const BlocksField: React.FC<Props> = (props) => {
|
||||
)
|
||||
|
||||
const removeRow = useCallback(
|
||||
async (rowIndex: number) => {
|
||||
await removeFieldRow({ path, rowIndex })
|
||||
(rowIndex: number) => {
|
||||
removeFieldRow({ path, rowIndex })
|
||||
setModified(true)
|
||||
},
|
||||
[path, removeFieldRow, setModified],
|
||||
@@ -297,7 +297,7 @@ const BlocksField: React.FC<Props> = (props) => {
|
||||
</DrawerToggler>
|
||||
<BlocksDrawer
|
||||
addRow={addRow}
|
||||
addRowIndex={value?.length || 0}
|
||||
addRowIndex={value || 0}
|
||||
blocks={blocks}
|
||||
drawerSlug={drawerSlug}
|
||||
labels={labels}
|
||||
|
||||
@@ -29,7 +29,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
|
||||
const dispatchField = useFormFields(([_, dispatch]) => dispatch)
|
||||
const config = useConfig()
|
||||
|
||||
const { getData, getSiblingData, setModified } = useForm()
|
||||
const { getData, getDataByPath, getSiblingData, setModified } = useForm()
|
||||
|
||||
const value = field?.value as T
|
||||
const initialValue = field?.initialValue as T
|
||||
@@ -116,8 +116,14 @@ const useField = <T,>(options: Options): FieldType<T> => {
|
||||
user,
|
||||
}
|
||||
|
||||
let valueToValidate = value
|
||||
|
||||
if (field?.rows && Array.isArray(field.rows)) {
|
||||
valueToValidate = getDataByPath(path)
|
||||
}
|
||||
|
||||
const validationResult =
|
||||
typeof validate === 'function' ? await validate(value, validateOptions) : true
|
||||
typeof validate === 'function' ? await validate(valueToValidate, validateOptions) : true
|
||||
|
||||
if (typeof validationResult === 'string') {
|
||||
action.errorMessage = validationResult
|
||||
@@ -132,7 +138,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
|
||||
}
|
||||
}
|
||||
|
||||
validateField()
|
||||
void validateField()
|
||||
},
|
||||
150,
|
||||
[
|
||||
@@ -142,6 +148,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
|
||||
dispatchField,
|
||||
getData,
|
||||
getSiblingData,
|
||||
getDataByPath,
|
||||
id,
|
||||
operation,
|
||||
path,
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
}
|
||||
|
||||
&__fields {
|
||||
& > .tabs-field {
|
||||
& > .tabs-field,
|
||||
& > .group-field {
|
||||
margin-right: calc(var(--base) * -2);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +52,7 @@
|
||||
position: sticky;
|
||||
top: var(--doc-controls-height);
|
||||
width: 33.33%;
|
||||
height: 100%;
|
||||
height: calc(100vh - var(--doc-controls-height));
|
||||
}
|
||||
|
||||
&__sidebar {
|
||||
@@ -110,7 +111,8 @@
|
||||
}
|
||||
|
||||
&__fields {
|
||||
& > .tabs-field {
|
||||
& > .tabs-field,
|
||||
& > .group-field {
|
||||
margin-right: calc(var(--gutter-h) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +90,8 @@ export const DefaultGlobalEdit: React.FC<GlobalEditViewProps> = (props) => {
|
||||
<div className={`${baseClass}__sidebar-sticky-wrap`}>
|
||||
<div className={`${baseClass}__sidebar-fields`}>
|
||||
<RenderFields
|
||||
fieldSchema={fields}
|
||||
fieldTypes={fieldTypes}
|
||||
filter={(field) => field.admin.position === 'sidebar'}
|
||||
fields={sidebarFields}
|
||||
permissions={permissions.fields}
|
||||
readOnly={!hasSavePermission}
|
||||
/>
|
||||
|
||||
@@ -21,4 +21,11 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__inputWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: base(1);
|
||||
margin-bottom: base(0.25);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ const Login: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const prefillForm = autoLogin && autoLogin.prefillOnly
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{user ? (
|
||||
@@ -75,22 +77,33 @@ const Login: React.FC = () => {
|
||||
action={`${serverURL}${api}/${userSlug}/login`}
|
||||
className={`${baseClass}__form`}
|
||||
disableSuccessStatus
|
||||
initialData={{
|
||||
email: autoLogin && autoLogin.prefillOnly ? autoLogin.email : undefined,
|
||||
password: autoLogin && autoLogin.prefillOnly ? autoLogin.password : undefined,
|
||||
}}
|
||||
initialData={
|
||||
prefillForm
|
||||
? {
|
||||
email: autoLogin.email,
|
||||
password: autoLogin.password,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
method="post"
|
||||
onSuccess={onSuccess}
|
||||
waitForAutocomplete
|
||||
>
|
||||
<FormLoadingOverlayToggle action="loading" name="login-form" />
|
||||
<Email
|
||||
admin={{ autoComplete: 'email' }}
|
||||
label={t('general:email')}
|
||||
name="email"
|
||||
required
|
||||
/>
|
||||
<Password autoComplete="off" label={t('general:password')} name="password" required />
|
||||
<div className={`${baseClass}__inputWrap`}>
|
||||
<Email
|
||||
admin={{ autoComplete: 'email' }}
|
||||
label={t('general:email')}
|
||||
name="email"
|
||||
required
|
||||
/>
|
||||
<Password
|
||||
autoComplete="off"
|
||||
label={t('general:password')}
|
||||
name="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Link to={`${admin}/forgot`}>{t('forgotPasswordQuestion')}</Link>
|
||||
<FormSubmit>{t('login')}</FormSubmit>
|
||||
</Form>
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
}
|
||||
|
||||
&__fields {
|
||||
& > .tabs-field {
|
||||
& > .tabs-field,
|
||||
& > .group-field {
|
||||
margin-right: calc(var(--base) * -2);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +56,7 @@
|
||||
position: sticky;
|
||||
top: var(--doc-controls-height);
|
||||
width: 33.33%;
|
||||
height: 100%;
|
||||
height: calc(100vh - var(--doc-controls-height));
|
||||
}
|
||||
|
||||
&__sidebar {
|
||||
@@ -106,7 +107,8 @@
|
||||
}
|
||||
|
||||
&__fields {
|
||||
& > .tabs-field {
|
||||
& > .tabs-field,
|
||||
& > .group-field {
|
||||
margin-right: calc(var(--gutter-h) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,12 @@ export const DefaultCollectionEdit: React.FC<CollectionEditViewProps> = (props)
|
||||
<div className={`${baseClass}__sidebar`}>
|
||||
<div className={`${baseClass}__sidebar-sticky-wrap`}>
|
||||
<div className={`${baseClass}__sidebar-fields`}>
|
||||
<RenderFields fieldTypes={fieldTypes} fields={sidebarFields} />
|
||||
<RenderFields
|
||||
fieldTypes={fieldTypes}
|
||||
fields={sidebarFields}
|
||||
permissions={permissions.fields}
|
||||
readOnly={!hasSavePermission}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -382,7 +382,7 @@ export const relationship: Validate<unknown, unknown, RelationshipField> = async
|
||||
})
|
||||
|
||||
if (invalidRelationships.length > 0) {
|
||||
return `This field has the following invalid selections: ${invalidRelationships
|
||||
return `This relationship field has the following invalid relationships: ${invalidRelationships
|
||||
.map((err, invalid) => {
|
||||
return `${err} ${JSON.stringify(invalid)}`
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/richtext-lexical",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.8",
|
||||
"description": "The officially supported Lexical richtext adapter for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -16,17 +16,38 @@ export const RichTextCell: React.FC<
|
||||
const [preview, setPreview] = React.useState('Loading...')
|
||||
|
||||
useEffect(() => {
|
||||
if (data == null) {
|
||||
let dataToUse = data
|
||||
if (dataToUse == null) {
|
||||
setPreview('')
|
||||
return
|
||||
}
|
||||
|
||||
// Transform data through load hooks
|
||||
if (editorConfig?.features?.hooks?.load?.length) {
|
||||
editorConfig.features.hooks.load.forEach((hook) => {
|
||||
dataToUse = hook({ incomingEditorState: dataToUse })
|
||||
})
|
||||
}
|
||||
|
||||
// If data is from Slate and not Lexical
|
||||
if (dataToUse && Array.isArray(dataToUse) && !('root' in dataToUse)) {
|
||||
setPreview('')
|
||||
return
|
||||
}
|
||||
|
||||
// If data is from payload-plugin-lexical
|
||||
if (dataToUse && 'jsonContent' in dataToUse) {
|
||||
setPreview('')
|
||||
return
|
||||
}
|
||||
|
||||
// initialize headless editor
|
||||
const headlessEditor = createHeadlessEditor({
|
||||
namespace: editorConfig.lexical.namespace,
|
||||
nodes: getEnabledNodes({ editorConfig }),
|
||||
theme: editorConfig.lexical.theme,
|
||||
})
|
||||
headlessEditor.setEditorState(headlessEditor.parseEditorState(data))
|
||||
headlessEditor.setEditorState(headlessEditor.parseEditorState(dataToUse))
|
||||
|
||||
const textContent =
|
||||
headlessEditor.getEditorState().read(() => {
|
||||
|
||||
@@ -89,9 +89,16 @@ const RichText: React.FC<FieldProps> = (props) => {
|
||||
fieldProps={props}
|
||||
initialState={initialValue}
|
||||
onChange={(editorState, editor, tags) => {
|
||||
const json = editorState.toJSON()
|
||||
let serializedEditorState = editorState.toJSON()
|
||||
|
||||
setValue(json)
|
||||
// Transform state through save hooks
|
||||
if (editorConfig?.features?.hooks?.save?.length) {
|
||||
editorConfig.features.hooks.save.forEach((hook) => {
|
||||
serializedEditorState = hook({ incomingEditorState: serializedEditorState })
|
||||
})
|
||||
}
|
||||
|
||||
setValue(serializedEditorState)
|
||||
}}
|
||||
readOnly={readOnly}
|
||||
setValue={setValue}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Block } from 'payload/types'
|
||||
|
||||
import { sanitizeFields } from 'payload/config'
|
||||
|
||||
import type { BlocksFeatureProps } from '.'
|
||||
@@ -20,40 +22,42 @@ export const blockAfterReadPromiseHOC = (
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
}) => {
|
||||
const blocks: Block[] = props.blocks
|
||||
const blockFieldData = node.fields.data
|
||||
|
||||
const promises: Promise<void>[] = []
|
||||
|
||||
// Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here
|
||||
const payloadConfig = req.payload.config
|
||||
const validRelationships = payloadConfig.collections.map((c) => c.slug) || []
|
||||
props.blocks = props.blocks.map((block) => {
|
||||
const unsanitizedBlock = { ...block }
|
||||
unsanitizedBlock.fields = sanitizeFields({
|
||||
blocks.forEach((block) => {
|
||||
block.fields = sanitizeFields({
|
||||
config: payloadConfig,
|
||||
fields: block.fields,
|
||||
validRelationships,
|
||||
})
|
||||
return unsanitizedBlock
|
||||
})
|
||||
|
||||
if (Array.isArray(props.blocks)) {
|
||||
props.blocks.forEach((block) => {
|
||||
if (block?.fields) {
|
||||
recurseNestedFields({
|
||||
afterReadPromises,
|
||||
currentDepth,
|
||||
data: node.fields.data || {},
|
||||
depth,
|
||||
fields: block.fields,
|
||||
overrideAccess,
|
||||
promises,
|
||||
req,
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
})
|
||||
}
|
||||
})
|
||||
// find block used in this node
|
||||
const block = props.blocks.find((block) => block.slug === blockFieldData.blockType)
|
||||
if (!block || !block?.fields?.length || !blockFieldData) {
|
||||
return promises
|
||||
}
|
||||
|
||||
recurseNestedFields({
|
||||
afterReadPromises,
|
||||
currentDepth,
|
||||
data: blockFieldData,
|
||||
depth,
|
||||
fields: block.fields,
|
||||
overrideAccess,
|
||||
promises,
|
||||
req,
|
||||
showHiddenFields,
|
||||
// The afterReadPromise gets its data from looking for field.name inside of the siblingDoc. Thus, here we cannot pass the whole document's siblingDoc, but only the siblingDoc (sibling fields) of the current field.
|
||||
siblingDoc: blockFieldData,
|
||||
})
|
||||
|
||||
return promises
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,11 @@ type Props = {
|
||||
nodeKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual content of the Block. This should be INSIDE a Form component,
|
||||
* scoped to the block. All format operations in here are thus scoped to the block's form, and
|
||||
* not the whole document.
|
||||
*/
|
||||
export const BlockContent: React.FC<Props> = (props) => {
|
||||
const { baseClass, block, field, fields, nodeKey } = props
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { type ElementFormatType } from 'lexical'
|
||||
import { Form, buildInitialState, useFormSubmitted } from 'payload/components/forms'
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
|
||||
import { type BlockFields } from '../nodes/BlocksNode'
|
||||
const baseClass = 'lexical-block'
|
||||
|
||||
import type { Data } from 'payload/types'
|
||||
|
||||
import { useConfig } from 'payload/components/utilities'
|
||||
import {
|
||||
buildStateFromSchema,
|
||||
useConfig,
|
||||
useDocumentInfo,
|
||||
useLocale,
|
||||
} from 'payload/components/utilities'
|
||||
import { sanitizeFields } from 'payload/config'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { BlocksFeatureProps } from '..'
|
||||
|
||||
@@ -43,13 +49,49 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
validRelationships,
|
||||
})
|
||||
|
||||
const initialDataRef = React.useRef<Data>(buildInitialState(fields.data || {})) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
|
||||
const initialStateRef = React.useRef<Data>(buildInitialState(fields.data || {})) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
|
||||
|
||||
const config = useConfig()
|
||||
const { t } = useTranslation('general')
|
||||
const { code: locale } = useLocale()
|
||||
const { getDocPreferences } = useDocumentInfo()
|
||||
|
||||
// initialState State
|
||||
|
||||
const [initialState, setInitialState] = React.useState<Data>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function buildInitialState() {
|
||||
const preferences = await getDocPreferences()
|
||||
|
||||
const stateFromSchema = await buildStateFromSchema({
|
||||
config,
|
||||
data: fields.data,
|
||||
fieldSchema: block.fields,
|
||||
locale,
|
||||
operation: 'update',
|
||||
preferences,
|
||||
t,
|
||||
})
|
||||
|
||||
// We have to merge the output of buildInitialState (above this useEffect) with the output of buildStateFromSchema.
|
||||
// That's because the output of buildInitialState provides important properties necessary for THIS block,
|
||||
// like blockName, blockType and id, while buildStateFromSchema provides the correct output of this block's data,
|
||||
// e.g. if this block has a sub-block (like the `rows` property)
|
||||
setInitialState({
|
||||
...initialStateRef?.current,
|
||||
...stateFromSchema,
|
||||
})
|
||||
}
|
||||
void buildInitialState()
|
||||
}, [setInitialState, config, block, locale, getDocPreferences, t]) // do not add fields here, it causes an endless loop
|
||||
|
||||
// Memoized Form JSX
|
||||
const formContent = useMemo(() => {
|
||||
return (
|
||||
block && (
|
||||
<Form initialState={initialDataRef?.current} submitted={submitted}>
|
||||
block &&
|
||||
initialState && (
|
||||
<Form fields={block.fields} initialState={initialState} submitted={submitted}>
|
||||
<BlockContent
|
||||
baseClass={baseClass}
|
||||
block={block}
|
||||
@@ -60,7 +102,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
</Form>
|
||||
)
|
||||
)
|
||||
}, [block, field, nodeKey, submitted])
|
||||
}, [block, field, nodeKey, submitted, initialState])
|
||||
|
||||
return <div className={baseClass}>{formContent}</div>
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ export const blockValidationHOC = (
|
||||
payloadConfig,
|
||||
validation,
|
||||
}) => {
|
||||
const blockFieldValues = node.fields.data
|
||||
|
||||
const blockFieldData = node.fields.data
|
||||
const blocks: Block[] = props.blocks
|
||||
|
||||
// Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here
|
||||
const validRelationships = payloadConfig.collections.map((c) => c.slug) || []
|
||||
blocks.forEach((block) => {
|
||||
const validRelationships = payloadConfig.collections.map((c) => c.slug) || []
|
||||
block.fields = sanitizeFields({
|
||||
config: payloadConfig,
|
||||
fields: block.fields,
|
||||
@@ -29,7 +29,7 @@ export const blockValidationHOC = (
|
||||
})
|
||||
|
||||
// find block
|
||||
const block = props.blocks.find((block) => block.slug === blockFieldValues.blockType)
|
||||
const block = props.blocks.find((block) => block.slug === blockFieldData.blockType)
|
||||
|
||||
// validate block
|
||||
if (!block) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export const linkAfterReadPromiseHOC = (
|
||||
promises,
|
||||
req,
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
siblingDoc: node.fields || {},
|
||||
})
|
||||
}
|
||||
return promises
|
||||
|
||||
@@ -51,7 +51,7 @@ export const uploadAfterReadPromiseHOC = (
|
||||
promises,
|
||||
req,
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
siblingDoc: node.fields || {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -51,6 +51,18 @@ export type Feature = {
|
||||
floatingSelectToolbar?: {
|
||||
sections: FloatingToolbarSection[]
|
||||
}
|
||||
hooks?: {
|
||||
load?: ({
|
||||
incomingEditorState,
|
||||
}: {
|
||||
incomingEditorState: SerializedEditorState
|
||||
}) => SerializedEditorState
|
||||
save?: ({
|
||||
incomingEditorState,
|
||||
}: {
|
||||
incomingEditorState: SerializedEditorState
|
||||
}) => SerializedEditorState
|
||||
}
|
||||
markdownTransformers?: Transformer[]
|
||||
nodes?: Array<{
|
||||
afterReadPromises?: Array<AfterReadPromise>
|
||||
@@ -123,6 +135,22 @@ export type SanitizedFeatures = Required<
|
||||
floatingSelectToolbar: {
|
||||
sections: FloatingToolbarSection[]
|
||||
}
|
||||
hooks: {
|
||||
load: Array<
|
||||
({
|
||||
incomingEditorState,
|
||||
}: {
|
||||
incomingEditorState: SerializedEditorState
|
||||
}) => SerializedEditorState
|
||||
>
|
||||
save: Array<
|
||||
({
|
||||
incomingEditorState,
|
||||
}: {
|
||||
incomingEditorState: SerializedEditorState
|
||||
}) => SerializedEditorState
|
||||
>
|
||||
}
|
||||
plugins?: Array<
|
||||
| {
|
||||
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
|
||||
|
||||
@@ -21,7 +21,16 @@ export type LexicalProviderProps = {
|
||||
value: SerializedEditorState
|
||||
}
|
||||
export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => {
|
||||
const { editorConfig, fieldProps, initialState, onChange, readOnly, setValue, value } = props
|
||||
const { editorConfig, fieldProps, onChange, readOnly, setValue } = props
|
||||
let { initialState, value } = props
|
||||
|
||||
// Transform initialState through load hooks
|
||||
if (editorConfig?.features?.hooks?.load?.length) {
|
||||
editorConfig.features.hooks.load.forEach((hook) => {
|
||||
initialState = hook({ incomingEditorState: initialState })
|
||||
value = hook({ incomingEditorState: value })
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
(value && Array.isArray(value) && !('root' in value)) ||
|
||||
|
||||
@@ -10,6 +10,10 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
|
||||
floatingSelectToolbar: {
|
||||
sections: [],
|
||||
},
|
||||
hooks: {
|
||||
load: [],
|
||||
save: [],
|
||||
},
|
||||
markdownTransformers: [],
|
||||
nodes: [],
|
||||
plugins: [],
|
||||
@@ -21,6 +25,15 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
|
||||
}
|
||||
|
||||
features.forEach((feature) => {
|
||||
if (feature.hooks) {
|
||||
if (feature.hooks?.load?.length) {
|
||||
sanitized.hooks.load = sanitized.hooks.load.concat(feature.hooks.load)
|
||||
}
|
||||
if (feature.hooks?.save?.length) {
|
||||
sanitized.hooks.save = sanitized.hooks.save.concat(feature.hooks.save)
|
||||
}
|
||||
}
|
||||
|
||||
if (feature.nodes?.length) {
|
||||
sanitized.nodes = sanitized.nodes.concat(feature.nodes)
|
||||
feature.nodes.forEach((node) => {
|
||||
|
||||
124
packages/richtext-lexical/src/field/lexical/utils/nodeFormat.ts
Normal file
124
packages/richtext-lexical/src/field/lexical/utils/nodeFormat.ts
Normal 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',
|
||||
}
|
||||
@@ -153,6 +153,7 @@ export { IndentFeature } from './field/features/indent'
|
||||
export { CheckListFeature } from './field/features/lists/CheckList'
|
||||
export { OrderedListFeature } from './field/features/lists/OrderedList'
|
||||
export { UnoderedListFeature } from './field/features/lists/UnorderedList'
|
||||
export { SlateToLexicalFeature } from './field/features/migrations/SlateToLexical'
|
||||
export type {
|
||||
AfterReadPromise,
|
||||
Feature,
|
||||
@@ -201,6 +202,20 @@ export { isHTMLElement } from './field/lexical/utils/guard'
|
||||
export { invariant } from './field/lexical/utils/invariant'
|
||||
export { joinClasses } from './field/lexical/utils/joinClasses'
|
||||
export { createBlockNode } from './field/lexical/utils/markdown/createBlockNode'
|
||||
export {
|
||||
DETAIL_TYPE_TO_DETAIL,
|
||||
DOUBLE_LINE_BREAK,
|
||||
ELEMENT_FORMAT_TO_TYPE,
|
||||
ELEMENT_TYPE_TO_FORMAT,
|
||||
IS_ALL_FORMATTING,
|
||||
LTR_REGEX,
|
||||
NON_BREAKING_SPACE,
|
||||
NodeFormat,
|
||||
RTL_REGEX,
|
||||
TEXT_MODE_TO_TYPE,
|
||||
TEXT_TYPE_TO_FORMAT,
|
||||
TEXT_TYPE_TO_MODE,
|
||||
} from './field/lexical/utils/nodeFormat'
|
||||
export { Point, isPoint } from './field/lexical/utils/point'
|
||||
export { Rect } from './field/lexical/utils/rect'
|
||||
export { setFloatingElemPosition } from './field/lexical/utils/setFloatingElemPosition'
|
||||
|
||||
@@ -173,7 +173,7 @@ export const recurseNestedFields = ({
|
||||
promises,
|
||||
req,
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
siblingDoc: data[field.name][i], // This has to be scoped to the blocks's fields, otherwise there may be population issues, e.g. for a relationship field with Blocks Node, with a Blocks Field, with a RichText Field, With Relationship Node. The last richtext field would try to find itself using siblingDoc[field.nane], which only works if the siblingDoc is scoped to the blocks's fields
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -191,14 +191,13 @@ export const recurseNestedFields = ({
|
||||
promises,
|
||||
req,
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
siblingDoc, // TODO: if there's any population issues, this might have to be data[field.name][i] as well
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'richText') {
|
||||
// TODO: This does not properly work yet. E.g. it does not handle a relationship inside of lexical inside of block inside of lexical
|
||||
const editor: RichTextAdapter = field?.editor
|
||||
|
||||
if (editor?.afterReadPromise) {
|
||||
|
||||
@@ -59,7 +59,7 @@ export const recurseRichText = ({
|
||||
}
|
||||
}
|
||||
|
||||
if ('children' in node && node?.children) {
|
||||
if ('children' in node && Array.isArray(node?.children) && node?.children?.length) {
|
||||
recurseRichText({
|
||||
afterReadPromises,
|
||||
children: node.children as SerializedLexicalNode[],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/richtext-slate",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "The officially supported Slate richtext adapter for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
'use client'
|
||||
import type { CellComponentProps, RichTextField } from 'payload/types'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
'use client'
|
||||
import type { ElementType } from 'react'
|
||||
|
||||
import { Tooltip } from 'payload/components'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
'use client'
|
||||
import { Editor, Transforms } from 'slate'
|
||||
import { ReactEditor } from 'slate-react'
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
'use client'
|
||||
import { ShimmerEffect } from 'payload/components'
|
||||
import React, { Suspense, lazy } from 'react'
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useSlate } from 'slate-react'
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
23
test/admin/collections/CustomViews1.ts
Normal file
23
test/admin/collections/CustomViews1.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
41
test/admin/collections/CustomViews2.ts
Normal file
41
test/admin/collections/CustomViews2.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
11
test/admin/collections/Geo.ts
Normal file
11
test/admin/collections/Geo.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
16
test/admin/collections/Group1A.ts
Normal file
16
test/admin/collections/Group1A.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
16
test/admin/collections/Group1B.ts
Normal file
16
test/admin/collections/Group1B.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
14
test/admin/collections/Group2A.ts
Normal file
14
test/admin/collections/Group2A.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
14
test/admin/collections/Group2B.ts
Normal file
14
test/admin/collections/Group2B.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
14
test/admin/collections/Hidden.ts
Normal file
14
test/admin/collections/Hidden.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
96
test/admin/collections/Posts.ts
Normal file
96
test/admin/collections/Posts.ts
Normal 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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
10
test/admin/collections/Users.ts
Normal file
10
test/admin/collections/Users.ts
Normal 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: [],
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { AdminViewComponent } from '../../../../../packages/payload/src/con
|
||||
import { useStepNav } from '../../../../../packages/payload/src/admin/components/elements/StepNav'
|
||||
import { useConfig } from '../../../../../packages/payload/src/admin/components/utilities/Config'
|
||||
|
||||
const CustomDefaultView: AdminViewComponent = ({
|
||||
const CustomDefaultEditView: AdminViewComponent = ({
|
||||
canAccessAdmin,
|
||||
// collection,
|
||||
// global,
|
||||
@@ -72,4 +72,4 @@ const CustomDefaultView: AdminViewComponent = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomDefaultView
|
||||
export default CustomDefaultEditView
|
||||
|
||||
@@ -1,23 +1,31 @@
|
||||
import path from 'path'
|
||||
|
||||
import { mapAsync } from '../../packages/payload/src/utilities/mapAsync'
|
||||
import { slateEditor } from '../../packages/richtext-slate/src'
|
||||
import { buildConfigWithDefaults } from '../buildConfigWithDefaults'
|
||||
import { devUser } from '../credentials'
|
||||
import { CustomViews1 } from './collections/CustomViews1'
|
||||
import { CustomViews2 } from './collections/CustomViews2'
|
||||
import { Geo } from './collections/Geo'
|
||||
import { CollectionGroup1A } from './collections/Group1A'
|
||||
import { CollectionGroup1B } from './collections/Group1B'
|
||||
import { CollectionGroup2A } from './collections/Group2A'
|
||||
import { CollectionGroup2B } from './collections/Group2B'
|
||||
import { CollectionHidden } from './collections/Hidden'
|
||||
import { Posts } from './collections/Posts'
|
||||
import { Users } from './collections/Users'
|
||||
import AfterDashboard from './components/AfterDashboard'
|
||||
import AfterNavLinks from './components/AfterNavLinks'
|
||||
import BeforeLogin from './components/BeforeLogin'
|
||||
import CustomTabComponent from './components/CustomTabComponent'
|
||||
import DemoUIFieldCell from './components/DemoUIField/Cell'
|
||||
import DemoUIFieldField from './components/DemoUIField/Field'
|
||||
import Logout from './components/Logout'
|
||||
import CustomDefaultView from './components/views/CustomDefault'
|
||||
import CustomDefaultEditView from './components/views/CustomDefaultEdit'
|
||||
import CustomEditView from './components/views/CustomEdit'
|
||||
import CustomMinimalRoute from './components/views/CustomMinimal'
|
||||
import CustomVersionsView from './components/views/CustomVersions'
|
||||
import CustomView from './components/views/CustomView'
|
||||
import { globalSlug, postsSlug, slugPluralLabel, slugSingularLabel } from './shared'
|
||||
import { CustomGlobalViews1 } from './globals/CustomViews1'
|
||||
import { CustomGlobalViews2 } from './globals/CustomViews2'
|
||||
import { Global } from './globals/Global'
|
||||
import { GlobalGroup1A } from './globals/Group1A'
|
||||
import { GlobalGroup1B } from './globals/Group1B'
|
||||
import { GlobalHidden } from './globals/Hidden'
|
||||
import { postsSlug } from './shared'
|
||||
|
||||
export interface Post {
|
||||
createdAt: Date
|
||||
@@ -66,321 +74,24 @@ export default buildConfigWithDefaults({
|
||||
locales: ['en', 'es'],
|
||||
},
|
||||
collections: [
|
||||
{
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
admin: {
|
||||
useAsTitle: 'email',
|
||||
},
|
||||
fields: [],
|
||||
},
|
||||
{
|
||||
slug: 'hidden-collection',
|
||||
admin: {
|
||||
hidden: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: postsSlug,
|
||||
labels: {
|
||||
singular: slugSingularLabel,
|
||||
plural: slugPluralLabel,
|
||||
},
|
||||
admin: {
|
||||
description: 'Description',
|
||||
listSearchableFields: ['title', 'description', 'number'],
|
||||
group: 'One',
|
||||
useAsTitle: 'title',
|
||||
defaultColumns: ['id', 'number', 'title', 'description', 'demoUIField'],
|
||||
preview: () => 'https://payloadcms.com',
|
||||
},
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
type: 'tabs',
|
||||
tabs: [
|
||||
{
|
||||
label: 'Tab 1',
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'description',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'number',
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
editor: slateEditor({
|
||||
admin: {
|
||||
elements: ['relationship'],
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'ui',
|
||||
name: 'demoUIField',
|
||||
label: 'Demo UI Field',
|
||||
admin: {
|
||||
components: {
|
||||
Field: DemoUIFieldField,
|
||||
Cell: DemoUIFieldCell,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'sidebarField',
|
||||
type: 'text',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
description:
|
||||
'This is a very long description that takes many characters to complete and hopefully will wrap instead of push the sidebar open, lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatum voluptates. Quisquam, voluptatum voluptates.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'custom-views-one',
|
||||
versions: true,
|
||||
admin: {
|
||||
components: {
|
||||
views: {
|
||||
// This will override the entire Edit view including all nested views, i.e. `/edit/:id/*`
|
||||
// To override one specific nested view, use the nested view's slug as the key
|
||||
Edit: CustomEditView,
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'custom-views-two',
|
||||
versions: true,
|
||||
admin: {
|
||||
components: {
|
||||
views: {
|
||||
Edit: {
|
||||
// This will override one specific nested view within the `/edit/:id` route, i.e. `/edit/:id/versions`
|
||||
Default: CustomDefaultEditView,
|
||||
Versions: CustomVersionsView,
|
||||
MyCustomView: {
|
||||
path: '/custom-tab-view',
|
||||
Component: CustomView,
|
||||
Tab: {
|
||||
label: 'Custom',
|
||||
href: '/custom-tab-view',
|
||||
},
|
||||
},
|
||||
MyCustomViewWithCustomTab: {
|
||||
path: '/custom-tab-component',
|
||||
Component: CustomView,
|
||||
Tab: CustomTabComponent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'group-one-collection-ones',
|
||||
admin: {
|
||||
group: 'One',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'group-one-collection-twos',
|
||||
admin: {
|
||||
group: 'One',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'group-two-collection-ones',
|
||||
admin: {
|
||||
group: 'Two',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'group-two-collection-twos',
|
||||
admin: {
|
||||
group: 'Two',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'geo',
|
||||
fields: [
|
||||
{
|
||||
name: 'point',
|
||||
type: 'point',
|
||||
},
|
||||
],
|
||||
},
|
||||
Posts,
|
||||
Users,
|
||||
CollectionHidden,
|
||||
CustomViews1,
|
||||
CustomViews2,
|
||||
CollectionGroup1A,
|
||||
CollectionGroup1B,
|
||||
CollectionGroup2A,
|
||||
CollectionGroup2B,
|
||||
Geo,
|
||||
],
|
||||
globals: [
|
||||
{
|
||||
slug: 'hidden-global',
|
||||
admin: {
|
||||
hidden: () => true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: globalSlug,
|
||||
label: {
|
||||
en: 'My Global Label',
|
||||
},
|
||||
admin: {
|
||||
group: 'Group',
|
||||
},
|
||||
versions: {
|
||||
drafts: true,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'sidebarField',
|
||||
type: 'text',
|
||||
admin: {
|
||||
position: 'sidebar',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'custom-global-views-one',
|
||||
versions: true,
|
||||
admin: {
|
||||
components: {
|
||||
views: {
|
||||
Edit: CustomEditView,
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'custom-global-views-two',
|
||||
versions: true,
|
||||
admin: {
|
||||
components: {
|
||||
views: {
|
||||
Edit: {
|
||||
Default: CustomDefaultEditView,
|
||||
Versions: CustomVersionsView,
|
||||
MyCustomView: {
|
||||
path: '/custom-tab-view',
|
||||
Component: CustomView,
|
||||
Tab: {
|
||||
label: 'Custom',
|
||||
href: '/custom-tab-view',
|
||||
},
|
||||
},
|
||||
MyCustomViewWithCustomTab: {
|
||||
path: '/custom-tab-component',
|
||||
Component: CustomView,
|
||||
Tab: CustomTabComponent,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'group-globals-one',
|
||||
label: 'Group Globals 1',
|
||||
admin: {
|
||||
group: 'Group',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'group-globals-two',
|
||||
admin: {
|
||||
group: 'Group',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
GlobalHidden,
|
||||
Global,
|
||||
CustomGlobalViews1,
|
||||
CustomGlobalViews2,
|
||||
GlobalGroup1A,
|
||||
GlobalGroup1B,
|
||||
],
|
||||
onInit: async (payload) => {
|
||||
await payload.create({
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
} from '../helpers'
|
||||
import { AdminUrlUtil } from '../helpers/adminUrlUtil'
|
||||
import { initPayloadE2E } from '../helpers/configHelpers'
|
||||
import { globalSlug, postsSlug, slugPluralLabel } from './shared'
|
||||
import {
|
||||
globalSlug,
|
||||
group1Collection1Slug,
|
||||
group1GlobalSlug,
|
||||
postsSlug,
|
||||
slugPluralLabel,
|
||||
} from './shared'
|
||||
|
||||
const { afterEach, beforeAll, beforeEach, describe } = test
|
||||
|
||||
@@ -149,6 +155,38 @@ describe('admin', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui', () => {
|
||||
test('collection - should render preview button when `admin.preview` is set', async () => {
|
||||
const collectionWithPreview = new AdminUrlUtil(serverURL, postsSlug)
|
||||
await page.goto(collectionWithPreview.create)
|
||||
await page.locator('#field-title').fill(title)
|
||||
await saveDocAndAssert(page)
|
||||
await expect(page.locator('.btn.preview-btn')).toBeVisible()
|
||||
})
|
||||
|
||||
test('collection - should not render preview button when `admin.preview` is not set', async () => {
|
||||
const collectionWithoutPreview = new AdminUrlUtil(serverURL, group1Collection1Slug)
|
||||
await page.goto(collectionWithoutPreview.create)
|
||||
await page.locator('#field-title').fill(title)
|
||||
await saveDocAndAssert(page)
|
||||
await expect(page.locator('.btn.preview-btn')).toBeHidden()
|
||||
})
|
||||
|
||||
test('global - should render preview button when `admin.preview` is set', async () => {
|
||||
const globalWithPreview = new AdminUrlUtil(serverURL, globalSlug)
|
||||
await page.goto(globalWithPreview.global(globalSlug))
|
||||
await expect(page.locator('.btn.preview-btn')).toBeVisible()
|
||||
})
|
||||
|
||||
test('global - should not render preview button when `admin.preview` is not set', async () => {
|
||||
const globalWithoutPreview = new AdminUrlUtil(serverURL, group1GlobalSlug)
|
||||
await page.goto(globalWithoutPreview.global(group1GlobalSlug))
|
||||
await page.locator('#field-title').fill(title)
|
||||
await saveDocAndAssert(page)
|
||||
await expect(page.locator('.btn.preview-btn')).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
describe('doc titles', () => {
|
||||
test('collection - should render fallback titles when creating new', async () => {
|
||||
await page.goto(url.create)
|
||||
@@ -292,10 +330,12 @@ describe('admin', () => {
|
||||
await page.locator('input#select-all').check()
|
||||
await page.locator('.edit-many__toggle').click()
|
||||
await page.locator('.field-select .rs__control').click()
|
||||
const options = page.locator('.rs__option')
|
||||
const titleOption = options.locator('text=Title')
|
||||
|
||||
await expect(titleOption).toHaveText('Title')
|
||||
const titleOption = page.locator('.rs__option', {
|
||||
hasText: exactText('Title'),
|
||||
})
|
||||
|
||||
await expect(titleOption).toBeVisible()
|
||||
|
||||
await titleOption.click()
|
||||
const titleInput = page.locator('#field-title')
|
||||
|
||||
21
test/admin/globals/CustomViews1.ts
Normal file
21
test/admin/globals/CustomViews1.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
40
test/admin/globals/CustomViews2.ts
Normal file
40
test/admin/globals/CustomViews2.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
30
test/admin/globals/Global.ts
Normal file
30
test/admin/globals/Global.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
17
test/admin/globals/Group1A.ts
Normal file
17
test/admin/globals/Group1A.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
14
test/admin/globals/Group1B.ts
Normal file
14
test/admin/globals/Group1B.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
14
test/admin/globals/Hidden.ts
Normal file
14
test/admin/globals/Hidden.ts
Normal 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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
export const postsSlug = 'posts'
|
||||
|
||||
export const group1Collection1Slug = 'group-one-collection-ones'
|
||||
|
||||
export const group1Collection2Slug = 'group-one-collection-twos'
|
||||
|
||||
export const slugSingularLabel = 'Post'
|
||||
|
||||
export const slugPluralLabel = 'Posts'
|
||||
|
||||
export const globalSlug = 'global'
|
||||
|
||||
export const group1GlobalSlug = 'group-globals-one'
|
||||
|
||||
@@ -8,7 +8,7 @@ const baseClass = 'custom-blocks-field-management'
|
||||
|
||||
export const AddCustomBlocks: React.FC = () => {
|
||||
const { addFieldRow, replaceFieldRow } = useForm()
|
||||
const { value } = useField({ path: 'customBlocks' })
|
||||
const { value } = useField<number>({ path: 'customBlocks' })
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
@@ -47,12 +47,12 @@ export const AddCustomBlocks: React.FC = () => {
|
||||
replaceFieldRow({
|
||||
data: { block1Title: 'REPLACED BLOCK', blockType: 'block-1' },
|
||||
path: 'customBlocks',
|
||||
rowIndex: (Array.isArray(value) ? value.length : 0) - 1,
|
||||
rowIndex: value - 1,
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Replace Block {Array.isArray(value) ? value.length : 0}
|
||||
Replace Block {value}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,6 +83,16 @@ const ArrayFields: CollectionConfig = {
|
||||
type: 'text',
|
||||
name: 'text',
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
name: 'groupInRow',
|
||||
fields: [
|
||||
{
|
||||
type: 'text',
|
||||
name: 'textInGroupInRow',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
119
test/fields/collections/Lexical/blocks.ts
Normal file
119
test/fields/collections/Lexical/blocks.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
193
test/fields/collections/Lexical/generateLexicalRichText.ts
Normal file
193
test/fields/collections/Lexical/generateLexicalRichText.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
}
|
||||
90
test/fields/collections/Lexical/index.ts
Normal file
90
test/fields/collections/Lexical/index.ts
Normal 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(),
|
||||
}
|
||||
2
test/fields/collections/Lexical/loremIpsum.ts
Normal file
2
test/fields/collections/Lexical/loremIpsum.ts
Normal 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.'
|
||||
@@ -14,6 +14,7 @@ import DateFields, { dateDoc } from './collections/Date'
|
||||
import GroupFields, { groupDoc } from './collections/Group'
|
||||
import IndexedFields from './collections/Indexed'
|
||||
import JSONFields, { jsonDoc } from './collections/JSON'
|
||||
import { LexicalFields, LexicalRichTextDoc } from './collections/Lexical'
|
||||
import NumberFields, { numberDoc } from './collections/Number'
|
||||
import PointFields, { pointDoc } from './collections/Point'
|
||||
import RadioFields, { radiosDoc } from './collections/Radio'
|
||||
@@ -41,6 +42,7 @@ export default buildConfigWithDefaults({
|
||||
}),
|
||||
},
|
||||
collections: [
|
||||
LexicalFields,
|
||||
{
|
||||
slug: 'users',
|
||||
auth: true,
|
||||
@@ -147,6 +149,14 @@ export default buildConfigWithDefaults({
|
||||
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, formattedTextID),
|
||||
)
|
||||
|
||||
const lexicalRichTextDocWithRelId = JSON.parse(
|
||||
JSON.stringify(LexicalRichTextDoc)
|
||||
.replace(/"\{\{ARRAY_DOC_ID\}\}"/g, formattedID)
|
||||
.replace(/"\{\{UPLOAD_DOC_ID\}\}"/g, formattedJPGID)
|
||||
.replace(/"\{\{TEXT_DOC_ID\}\}"/g, formattedTextID),
|
||||
)
|
||||
await payload.create({ collection: 'lexical-fields', data: lexicalRichTextDocWithRelId })
|
||||
|
||||
const richTextDocWithRelationship = { ...richTextDocWithRelId }
|
||||
|
||||
await payload.create({ collection: 'rich-text-fields', data: richTextBulletsDocWithRelId })
|
||||
|
||||
@@ -505,120 +505,117 @@ describe('fields', () => {
|
||||
})
|
||||
|
||||
describe('row manipulation', () => {
|
||||
test('should add 2 new rows', async () => {
|
||||
test('should add, remove and duplicate rows', async () => {
|
||||
const assertText0 = 'array row 1'
|
||||
const assertGroupText0 = 'text in group in row 1'
|
||||
const assertText1 = 'array row 2'
|
||||
const assertText3 = 'array row 3'
|
||||
const assertGroupText3 = 'text in group in row 3'
|
||||
await page.goto(url.create)
|
||||
|
||||
// Add 3 rows
|
||||
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
|
||||
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
|
||||
await page.locator('#field-potentiallyEmptyArray__0__text').fill('array row 1')
|
||||
await page.locator('#field-potentiallyEmptyArray__1__text').fill('array row 2')
|
||||
|
||||
await saveDocAndAssert(page)
|
||||
})
|
||||
|
||||
test('should remove 2 new rows', async () => {
|
||||
await page.goto(url.create)
|
||||
|
||||
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
|
||||
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
|
||||
await page.locator('#field-potentiallyEmptyArray__0__text').fill('array row 1')
|
||||
await page.locator('#field-potentiallyEmptyArray__1__text').fill('array row 2')
|
||||
|
||||
await page.locator('#potentiallyEmptyArray-row-1 .array-actions__button').click()
|
||||
// Fill out row 1
|
||||
await page.locator('#field-potentiallyEmptyArray__0__text').fill(assertText0)
|
||||
await page
|
||||
.locator('#potentiallyEmptyArray-row-1 .popup__scroll-container .array-actions__remove')
|
||||
.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow')
|
||||
.fill(assertGroupText0)
|
||||
// Fill out row 2
|
||||
await page.locator('#field-potentiallyEmptyArray__1__text').fill(assertText1)
|
||||
// Fill out row 3
|
||||
await page.locator('#field-potentiallyEmptyArray__2__text').fill(assertText3)
|
||||
await page
|
||||
.locator('#field-potentiallyEmptyArray__2__groupInRow__textInGroupInRow')
|
||||
.fill(assertGroupText3)
|
||||
|
||||
// Remove row 1
|
||||
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
|
||||
await page
|
||||
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove')
|
||||
.click()
|
||||
// Remove row 2
|
||||
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
|
||||
await page
|
||||
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove')
|
||||
.click()
|
||||
|
||||
const rows = page.locator('#field-potentiallyEmptyArray > .array-field__draggable-rows')
|
||||
|
||||
await expect(rows).toBeHidden()
|
||||
})
|
||||
|
||||
test('should remove existing row', async () => {
|
||||
await page.goto(url.create)
|
||||
|
||||
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
|
||||
await page.locator('#field-potentiallyEmptyArray__0__text').fill('array row 1')
|
||||
|
||||
// Save document
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
// Scroll to array row (fields are not rendered in DOM until on screen)
|
||||
await page.locator('#field-potentiallyEmptyArray__0__groupInRow').scrollIntoViewIfNeeded()
|
||||
|
||||
// Expect the remaining row to be the third row
|
||||
const input = page.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow')
|
||||
await expect(input).toHaveValue(assertGroupText3)
|
||||
|
||||
// Duplicate row
|
||||
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
|
||||
await page
|
||||
.locator(
|
||||
'#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__action.array-actions__remove',
|
||||
'#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__duplicate',
|
||||
)
|
||||
.click()
|
||||
|
||||
const rows = page.locator('#field-potentiallyEmptyArray > .array-field__draggable-rows')
|
||||
// Update duplicated row group field text
|
||||
await page
|
||||
.locator('#field-potentiallyEmptyArray__1__groupInRow__textInGroupInRow')
|
||||
.fill(`${assertGroupText3} duplicate`)
|
||||
|
||||
await expect(rows).toBeHidden()
|
||||
// Save document
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
// Expect the second row to be a duplicate of the remaining row
|
||||
await expect(
|
||||
page.locator('#field-potentiallyEmptyArray__1__groupInRow__textInGroupInRow'),
|
||||
).toHaveValue(`${assertGroupText3} duplicate`)
|
||||
|
||||
// Remove row 1
|
||||
await page.locator('#potentiallyEmptyArray-row-0 .array-actions__button').click()
|
||||
await page
|
||||
.locator('#potentiallyEmptyArray-row-0 .popup__scroll-container .array-actions__remove')
|
||||
.click()
|
||||
|
||||
// Save document
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
// Expect the remaining row to be the copy of the duplicate row
|
||||
await expect(
|
||||
page.locator('#field-potentiallyEmptyArray__0__groupInRow__textInGroupInRow'),
|
||||
).toHaveValue(`${assertGroupText3} duplicate`)
|
||||
})
|
||||
|
||||
test('should add row after removing existing row', async () => {
|
||||
await page.goto(url.create)
|
||||
describe('react hooks', () => {
|
||||
test('should add 2 new block rows', async () => {
|
||||
await page.goto(url.create)
|
||||
|
||||
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
|
||||
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
|
||||
await page.locator('#field-potentiallyEmptyArray__0__text').fill('array row 1')
|
||||
await page.locator('#field-potentiallyEmptyArray__1__text').fill('array row 2')
|
||||
await page
|
||||
.locator('.custom-blocks-field-management')
|
||||
.getByRole('button', { name: 'Add Block 1' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('#field-customBlocks input[name="customBlocks.0.block1Title"]'),
|
||||
).toHaveValue('Block 1: Prefilled Title')
|
||||
|
||||
await saveDocAndAssert(page)
|
||||
await page
|
||||
.locator('.custom-blocks-field-management')
|
||||
.getByRole('button', { name: 'Add Block 2' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('#field-customBlocks input[name="customBlocks.1.block2Title"]'),
|
||||
).toHaveValue('Block 2: Prefilled Title')
|
||||
|
||||
await page.locator('#potentiallyEmptyArray-row-1 .array-actions__button').click()
|
||||
await page
|
||||
.locator(
|
||||
'#potentiallyEmptyArray-row-1 .popup__scroll-container .array-actions__action.array-actions__remove',
|
||||
)
|
||||
.click()
|
||||
await page.locator('#field-potentiallyEmptyArray > .array-field__add-row').click()
|
||||
|
||||
await page.locator('#field-potentiallyEmptyArray__1__text').fill('updated array row 2')
|
||||
|
||||
await saveDocAndAssert(page)
|
||||
|
||||
const rowsContainer = page.locator(
|
||||
'#field-potentiallyEmptyArray > .array-field__draggable-rows',
|
||||
)
|
||||
const directChildDivCount = await rowsContainer.evaluate((element) => {
|
||||
const childDivCount = element.querySelectorAll(':scope > div')
|
||||
return childDivCount.length
|
||||
await page
|
||||
.locator('.custom-blocks-field-management')
|
||||
.getByRole('button', { name: 'Replace Block 2' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('#field-customBlocks input[name="customBlocks.1.block1Title"]'),
|
||||
).toHaveValue('REPLACED BLOCK')
|
||||
})
|
||||
|
||||
expect(directChildDivCount).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('row react hooks', () => {
|
||||
test('should add 2 new block rows', async () => {
|
||||
await page.goto(url.create)
|
||||
|
||||
await page
|
||||
.locator('.custom-blocks-field-management')
|
||||
.getByRole('button', { name: 'Add Block 1' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('#field-customBlocks input[name="customBlocks.0.block1Title"]'),
|
||||
).toHaveValue('Block 1: Prefilled Title')
|
||||
|
||||
await page
|
||||
.locator('.custom-blocks-field-management')
|
||||
.getByRole('button', { name: 'Add Block 2' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('#field-customBlocks input[name="customBlocks.1.block2Title"]'),
|
||||
).toHaveValue('Block 2: Prefilled Title')
|
||||
|
||||
await page
|
||||
.locator('.custom-blocks-field-management')
|
||||
.getByRole('button', { name: 'Replace Block 2' })
|
||||
.click()
|
||||
await expect(
|
||||
page.locator('#field-customBlocks input[name="customBlocks.1.block1Title"]'),
|
||||
).toHaveValue('REPLACED BLOCK')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user