Merge branch 'main' of github.com:payloadcms/payload

This commit is contained in:
James
2023-10-14 11:51:13 -04:00
156 changed files with 4210 additions and 837 deletions

View File

@@ -13,3 +13,6 @@ dfac7395fed95fc5d8ebca21b786ce70821942bb
# lint and format plugin-cloud
fb7d1be2f3325d076b7c967b1730afcef37922c2
# lint and format create-payload-app
5fd3d430001efe86515262ded5e26f00c1451181

View File

@@ -214,6 +214,7 @@ jobs:
matrix:
pkg:
- plugin-cloud
- create-payload-app
steps:
- name: Use Node.js 18
@@ -238,3 +239,4 @@ jobs:
- name: Test ${{ matrix.pkg }}
run: pnpm --filter ${{ matrix.pkg }} run test
if: matrix.pkg != 'create-payload-app' # degit doesn't work within GitHub Actions

View File

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

View File

@@ -88,13 +88,14 @@ This package provides the following functions:
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`subscribe`** | Subscribes to the Admin panel's `window.postMessage` events and calls the provided callback function. |
| **`unsubscribe`** | Unsubscribes from the Admin panel's `window.postMessage` events. |
| **`ready`** | Sends a `window.postMessage` event to the Admin panel to indicate that the front-end is ready to receive messages. |
The `subscribe` function takes the following args:
| Path | Description |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`callback`** \* | A callback function that is called with `data` every time a change is made to the document. |
| **`serverURL`** \* | The URL of your Payload server. git s |
| **`serverURL`** \* | The URL of your Payload server. |
| **`initialData`** | The initial data of the document. The live data will be merged in as changes are made. |
| **`depth`** | The depth of the relationships to fetch. Defaults to `0`. |
@@ -103,18 +104,23 @@ With these functions, you can build your own hook using your front-end framework
```tsx
import { subscribe, unsubscribe } from '@payloadcms/live-preview';
// Build your own hook to subscribe to the live preview events
// This function will handle everything for you like
// 1. subscribing to `window.postMessage` events
// 2. merging initial page data with incoming form state
// 3. populating relationships and uploads
// To build your own hook, subscribe to Live Preview events using the`subscribe` function
// It handles everything from:
// 1. Listening to `window.postMessage` events
// 2. Merging initial data with active form state
// 3. Populating relationships and uploads
// 4. Calling the `onChange` callback with the result
// Your hook should also:
// 1. Tell the Admin panel when it is ready to receive messages
// 2. Handle the results of the `onChange` callback to update the UI
// 3. Unsubscribe from the `window.postMessage` events when it unmounts
```
Here is an example of what the same `useLivePreview` React hook from above looks like under the hood:
```tsx
import { subscribe, unsubscribe } from '@payloadcms/live-preview'
import { useCallback, useEffect, useState } from 'react'
import { subscribe, unsubscribe, ready } from '@payloadcms/live-preview'
import { useCallback, useEffect, useState, useRef } from 'react'
export const useLivePreview = <T extends any>(props: {
depth?: number
@@ -127,13 +133,18 @@ export const useLivePreview = <T extends any>(props: {
const { depth = 0, initialData, serverURL } = props
const [data, setData] = useState<T>(initialData)
const [isLoading, setIsLoading] = useState<boolean>(true)
const hasSentReadyMessage = useRef<boolean>(false)
const onChange = useCallback((mergedData) => {
// When a change is made, the `onChange` callback will be called with the merged data
// Set this merged data into state so that React will re-render the UI
setData(mergedData)
setIsLoading(false)
}, [])
useEffect(() => {
// Listen for `window.postMessage` events from the Admin panel
// When a change is made, the `onChange` callback will be called with the merged data
const subscription = subscribe({
callback: onChange,
depth,
@@ -141,6 +152,17 @@ export const useLivePreview = <T extends any>(props: {
serverURL,
})
// Once subscribed, send a `ready` message back up to the Admin panel
// This will indicate that the front-end is ready to receive messages
if (!hasSentReadyMessage.current) {
hasSentReadyMessage.current = true
ready({
serverURL
})
}
// When the component unmounts, unsubscribe from the `window.postMessage` events
return () => {
unsubscribe(subscription)
}

View File

@@ -85,7 +85,7 @@ Here is an example of using a function that returns a dynamic URL:
locale
}) => `${data.tenant.url}${ // Multi-tenant top-level domain
documentInfo.slug === 'posts' ? `/posts/${data.slug}` : `${data.slug !== 'home' : `/${data.slug}` : ''}`
`}?locale=${locale}`, // Localization query param
}${locale ? `?locale=${locale?.code}` : ''}`, // Localization query param
collections: ['pages'],
},
}

View File

@@ -20,8 +20,8 @@
"lint-staged": "lint-staged",
"pretest": "pnpm build",
"reinstall": "pnpm clean:unix && pnpm install",
"list:packages": "./scripts/list_published_packages.sh beta",
"script:release:beta": "./scripts/release_beta.sh",
"script:list-packages": "tsx ./scripts/list-packages.ts",
"script:release": "tsx ./scripts/release.ts",
"test": "pnpm test:int && pnpm test:components && pnpm test:e2e",
"test:components": "cross-env jest --config=jest.components.config.js",
"test:e2e": "npx playwright install --with-deps && ts-node -T ./test/runE2E.ts",
@@ -74,6 +74,7 @@
"qs": "6.11.2",
"rimraf": "3.0.2",
"shelljs": "0.8.5",
"simple-git": "^3.20.0",
"slash": "3.0.0",
"slate": "0.91.4",
"ts-node": "10.9.1",

View File

@@ -0,0 +1,44 @@
/** @type {import('prettier').Config} */
module.exports = {
extends: ['@payloadcms'],
ignorePatterns: ['README.md', '**/*.spec.ts'],
overrides: [
{
extends: ['plugin:@typescript-eslint/disable-type-checked'],
files: ['*.js', '*.cjs', '*.json', '*.md', '*.yml', '*.yaml'],
},
{
files: ['**/*.ts', '**/*.tsx'],
rules: {
'no-console': 'off',
},
},
{
files: ['package.json', 'tsconfig.json'],
rules: {
'perfectionist/sort-array-includes': 'off',
'perfectionist/sort-astro-attributes': 'off',
'perfectionist/sort-classes': 'off',
'perfectionist/sort-enums': 'off',
'perfectionist/sort-exports': 'off',
'perfectionist/sort-imports': 'off',
'perfectionist/sort-interfaces': 'off',
'perfectionist/sort-jsx-props': 'off',
'perfectionist/sort-keys': 'off',
'perfectionist/sort-maps': 'off',
'perfectionist/sort-named-exports': 'off',
'perfectionist/sort-named-imports': 'off',
'perfectionist/sort-object-types': 'off',
'perfectionist/sort-objects': 'off',
'perfectionist/sort-svelte-attributes': 'off',
'perfectionist/sort-union-types': 'off',
'perfectionist/sort-vue-attributes': 'off',
},
},
],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
root: true,
}

View File

@@ -0,0 +1,34 @@
# Create Payload App
CLI for easily starting new Payload project
## Usage
```text
USAGE
$ npx create-payload-app
$ npx create-payload-app my-project
$ npx create-payload-app -n my-project -t blog
OPTIONS
-n my-payload-app Set project name
-t template_name Choose specific template
Available templates:
blank Blank Template
website Website Template
ecommerce E-commerce Template
plugin Template for creating a Payload plugin
payload-demo Payload demo site at https://demo.payloadcms.com
payload-website Payload website CMS at https://payloadcms.com
--use-npm Use npm to install dependencies
--use-yarn Use yarn to install dependencies
--use-pnpm Use pnpm to install dependencies
--no-deps Do not install any dependencies
-h Show help
```

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env node
require('../dist/index.js')

View File

@@ -0,0 +1,9 @@
module.exports = {
testEnvironment: 'node',
testMatch: ['**/src/**/?(*.)+(spec|test|it-test).[tj]s?(x)'],
testTimeout: 10000,
transform: {
'^.+\\.(ts|tsx)?$': 'ts-jest',
},
verbose: true,
}

View File

@@ -0,0 +1,47 @@
{
"name": "create-payload-app",
"version": "0.5.2",
"license": "MIT",
"bin": {
"create-payload-app": "bin/cli.js"
},
"scripts": {
"build": "tsc && pnpm copyfiles",
"copyfiles": "copyfiles -u 1 \"src/templates/**\" \"src/lib/common-files/**\" dist",
"clean": "rimraf dist",
"typecheck": "tsc --noEmit",
"lint": "eslint \"src/**/*.ts\"",
"lint:fix": "eslint \"src/**/*.ts\" --fix",
"lint-staged": "lint-staged --quiet",
"test": "jest",
"prepublishOnly": "pnpm test && pnpm clean && pnpm build"
},
"files": [
"package.json",
"dist",
"bin"
],
"dependencies": {
"@sindresorhus/slugify": "^1.1.0",
"arg": "^5.0.0",
"chalk": "^4.1.0",
"command-exists": "^1.2.9",
"degit": "^2.8.4",
"execa": "^5.0.0",
"figures": "^3.2.0",
"fs-extra": "^9.0.1",
"handlebars": "^4.7.7",
"ora": "^5.1.0",
"prompts": "^2.4.2",
"terminal-link": "^2.1.1"
},
"devDependencies": {
"@types/command-exists": "^1.2.0",
"@types/degit": "^2.8.3",
"@types/fs-extra": "^9.0.12",
"@types/jest": "^27.0.3",
"@types/node": "^16.6.2",
"@types/prompts": "^2.4.1",
"ts-jest": "^29.1.0"
}
}

View File

@@ -0,0 +1,8 @@
import { Main } from './main'
import { error } from './utils/log'
async function main(): Promise<void> {
await new Main().init()
}
main().catch((e) => error(`An error has occurred: ${e instanceof Error ? e.message : e}`))

View File

@@ -0,0 +1,117 @@
import fse from 'fs-extra'
import path from 'path'
import type { DbDetails } from '../types'
import { warning } from '../utils/log'
import { bundlerPackages, dbPackages, editorPackages } from './packages'
/** Update payload config with necessary imports and adapters */
export async function configurePayloadConfig(args: {
dbDetails: DbDetails | undefined
projectDir: string
}): Promise<void> {
if (!args.dbDetails) {
return
}
// Update package.json
const packageJsonPath = path.resolve(args.projectDir, 'package.json')
try {
const packageObj = await fse.readJson(packageJsonPath)
packageObj.dependencies['payload'] = '^2.0.0'
const dbPackage = dbPackages[args.dbDetails.type]
const bundlerPackage = bundlerPackages['webpack']
const editorPackage = editorPackages['slate']
// Delete all other db adapters
Object.values(dbPackages).forEach((p) => {
if (p.packageName !== dbPackage.packageName) {
delete packageObj.dependencies[p.packageName]
}
})
packageObj.dependencies[dbPackage.packageName] = dbPackage.version
packageObj.dependencies[bundlerPackage.packageName] = bundlerPackage.version
packageObj.dependencies[editorPackage.packageName] = editorPackage.version
await fse.writeJson(packageJsonPath, packageObj, { spaces: 2 })
} catch (err: unknown) {
warning('Unable to update name in package.json')
}
try {
const possiblePaths = [
path.resolve(args.projectDir, 'src/payload.config.ts'),
path.resolve(args.projectDir, 'src/payload/payload.config.ts'),
]
let payloadConfigPath: string | undefined
possiblePaths.forEach((p) => {
if (fse.pathExistsSync(p) && !payloadConfigPath) {
payloadConfigPath = p
}
})
if (!payloadConfigPath) {
warning('Unable to update payload.config.ts with plugins')
return
}
const configContent = fse.readFileSync(payloadConfigPath, 'utf-8')
const configLines = configContent.split('\n')
const dbReplacement = dbPackages[args.dbDetails.type]
const bundlerReplacement = bundlerPackages['webpack']
const editorReplacement = editorPackages['slate']
let dbConfigStartLineIndex: number | undefined
let dbConfigEndLineIndex: number | undefined
configLines.forEach((l, i) => {
if (l.includes('// database-adapter-import')) {
configLines[i] = dbReplacement.importReplacement
}
if (l.includes('// bundler-import')) {
configLines[i] = bundlerReplacement.importReplacement
}
if (l.includes('// bundler-config')) {
configLines[i] = bundlerReplacement.configReplacement
}
if (l.includes('// editor-import')) {
configLines[i] = editorReplacement.importReplacement
}
if (l.includes('// editor-config')) {
configLines[i] = editorReplacement.configReplacement
}
if (l.includes('// database-adapter-config-start')) {
dbConfigStartLineIndex = i
}
if (l.includes('// database-adapter-config-end')) {
dbConfigEndLineIndex = i
}
})
if (!dbConfigStartLineIndex || !dbConfigEndLineIndex) {
warning('Unable to update payload.config.ts with database adapter import')
} else {
// Replaces lines between `// database-adapter-config-start` and `// database-adapter-config-end`
configLines.splice(
dbConfigStartLineIndex,
dbConfigEndLineIndex - dbConfigStartLineIndex + 1,
...dbReplacement.configReplacement,
)
}
fse.writeFileSync(payloadConfigPath, configLines.join('\n'))
} catch (err: unknown) {
warning('Unable to update payload.config.ts with plugins')
}
}

View File

@@ -0,0 +1,151 @@
import fse from 'fs-extra'
import path from 'path'
import type { BundlerType, CliArgs, DbType, ProjectTemplate } from '../types'
import { createProject } from './create-project'
import { bundlerPackages, dbPackages, editorPackages } from './packages'
import exp from 'constants'
const projectDir = path.resolve(__dirname, './tmp')
describe('createProject', () => {
beforeAll(() => {
console.log = jest.fn()
})
beforeEach(() => {
if (fse.existsSync(projectDir)) {
fse.rmdirSync(projectDir, { recursive: true })
}
})
afterEach(() => {
if (fse.existsSync(projectDir)) {
fse.rmSync(projectDir, { recursive: true })
}
})
describe('#createProject', () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const args = {
_: ['project-name'],
'--db': 'mongodb',
'--no-deps': true,
} as CliArgs
const packageManager = 'yarn'
it('creates starter project', async () => {
const projectName = 'starter-project'
const template: ProjectTemplate = {
name: 'blank',
type: 'starter',
url: 'https://github.com/payloadcms/payload/templates/blank',
description: 'Blank Template',
}
await createProject({
cliArgs: args,
projectName,
projectDir,
template,
packageManager,
})
const packageJsonPath = path.resolve(projectDir, 'package.json')
const packageJson = fse.readJsonSync(packageJsonPath)
// Check package name and description
expect(packageJson.name).toEqual(projectName)
})
it('creates plugin template', async () => {
const projectName = 'plugin'
const template: ProjectTemplate = {
name: 'plugin',
type: 'plugin',
url: 'https://github.com/payloadcms/payload-plugin-template',
description: 'Template for creating a Payload plugin',
}
await createProject({
cliArgs: args,
projectName,
projectDir,
template,
packageManager,
})
const packageJsonPath = path.resolve(projectDir, 'package.json')
const packageJson = fse.readJsonSync(packageJsonPath)
// Check package name and description
expect(packageJson.name).toEqual(projectName)
})
describe('db adapters and bundlers', () => {
it.each([
['mongodb', 'webpack'],
['postgres', 'webpack'],
])('update config and deps: %s, %s', async (db, bundler) => {
const projectName = 'starter-project'
const template: ProjectTemplate = {
name: 'blank',
type: 'starter',
url: 'https://github.com/payloadcms/payload/templates/blank',
description: 'Blank Template',
}
await createProject({
cliArgs: args,
projectName,
projectDir,
template,
packageManager,
dbDetails: {
dbUri: `${db}://localhost:27017/create-project-test`,
type: db as DbType,
},
})
const dbReplacement = dbPackages[db as DbType]
const bundlerReplacement = bundlerPackages[bundler as BundlerType]
const editorReplacement = editorPackages['slate']
const packageJsonPath = path.resolve(projectDir, 'package.json')
const packageJson = fse.readJsonSync(packageJsonPath)
// Check deps
expect(packageJson.dependencies['payload']).toEqual('^2.0.0')
expect(packageJson.dependencies[dbReplacement.packageName]).toEqual(dbReplacement.version)
// Should only have one db adapter
expect(
Object.keys(packageJson.dependencies).filter((n) => n.startsWith('@payloadcms/db-')),
).toHaveLength(1)
expect(packageJson.dependencies[bundlerReplacement.packageName]).toEqual(
bundlerReplacement.version,
)
expect(packageJson.dependencies[editorReplacement.packageName]).toEqual(
editorReplacement.version,
)
const payloadConfigPath = path.resolve(projectDir, 'src/payload.config.ts')
const content = fse.readFileSync(payloadConfigPath, 'utf-8')
// Check payload.config.ts
expect(content).not.toContain('// database-adapter-import')
expect(content).toContain(dbReplacement.importReplacement)
expect(content).not.toContain('// database-adapter-config-start')
expect(content).not.toContain('// database-adapter-config-end')
expect(content).toContain(dbReplacement.configReplacement.join('\n'))
expect(content).not.toContain('// bundler-config-import')
expect(content).toContain(bundlerReplacement.importReplacement)
expect(content).not.toContain('// bundler-config')
expect(content).toContain(bundlerReplacement.configReplacement)
})
})
})
describe('Templates', () => {
it.todo('Verify that all templates are valid')
// Loop through all templates.ts that should have replacement comments, and verify that they are present
})
})

View File

@@ -0,0 +1,102 @@
import chalk from 'chalk'
import degit from 'degit'
import execa from 'execa'
import fse from 'fs-extra'
import ora from 'ora'
import path from 'path'
import type { CliArgs, DbDetails, PackageManager, ProjectTemplate } from '../types'
import { error, success, warning } from '../utils/log'
import { configurePayloadConfig } from './configure-payload-config'
async function createOrFindProjectDir(projectDir: string): Promise<void> {
const pathExists = await fse.pathExists(projectDir)
if (!pathExists) {
await fse.mkdir(projectDir)
}
}
async function installDeps(args: {
cliArgs: CliArgs
packageManager: PackageManager
projectDir: string
}): Promise<boolean> {
const { cliArgs, packageManager, projectDir } = args
if (cliArgs['--no-deps']) {
return true
}
let installCmd = 'npm install --legacy-peer-deps'
if (packageManager === 'yarn') {
installCmd = 'yarn'
} else if (packageManager === 'pnpm') {
installCmd = 'pnpm install'
}
try {
await execa.command(installCmd, {
cwd: path.resolve(projectDir),
})
return true
} catch (err: unknown) {
console.log({ err })
return false
}
}
export async function createProject(args: {
cliArgs: CliArgs
dbDetails?: DbDetails
packageManager: PackageManager
projectDir: string
projectName: string
template: ProjectTemplate
}): Promise<void> {
const { cliArgs, dbDetails, packageManager, projectDir, projectName, template } = args
await createOrFindProjectDir(projectDir)
console.log(`\n Creating project in ${chalk.green(path.resolve(projectDir))}\n`)
if ('url' in template) {
const emitter = degit(template.url)
await emitter.clone(projectDir)
}
const spinner = ora('Checking latest Payload version...').start()
await updatePackageJSON({ projectDir, projectName })
await configurePayloadConfig({ dbDetails, projectDir })
// Remove yarn.lock file. This is only desired in Payload Cloud.
const lockPath = path.resolve(projectDir, 'yarn.lock')
if (fse.existsSync(lockPath)) {
await fse.remove(lockPath)
}
spinner.text = 'Installing dependencies...'
const result = await installDeps({ cliArgs, packageManager, projectDir })
spinner.stop()
spinner.clear()
if (result) {
success('Dependencies installed')
} else {
error('Error installing dependencies')
}
}
export async function updatePackageJSON(args: {
projectDir: string
projectName: string
}): Promise<void> {
const { projectDir, projectName } = args
const packageJsonPath = path.resolve(projectDir, 'package.json')
try {
const packageObj = await fse.readJson(packageJsonPath)
packageObj.name = projectName
await fse.writeJson(packageJsonPath, packageObj, { spaces: 2 })
} catch (err: unknown) {
warning('Unable to update name in package.json')
}
}

View File

@@ -0,0 +1,5 @@
import { randomBytes } from 'crypto'
export function generateSecret(): string {
return randomBytes(32).toString('hex').slice(0, 24)
}

View File

@@ -0,0 +1,83 @@
import type { BundlerType, DbType, EditorType } from '../types'
type DbAdapterReplacement = {
configReplacement: string[]
importReplacement: string
packageName: string
version: string
}
type BundlerReplacement = {
configReplacement: string
importReplacement: string
packageName: string
version: string
}
type EditorReplacement = {
configReplacement: string
importReplacement: string
packageName: string
version: string
}
const mongodbReplacement: DbAdapterReplacement = {
importReplacement: "import { mongooseAdapter } from '@payloadcms/db-mongodb'",
packageName: '@payloadcms/db-mongodb',
// Replacement between `// database-adapter-config-start` and `// database-adapter-config-end`
configReplacement: [' db: mongooseAdapter({', ' url: process.env.DATABASE_URI,', ' }),'],
version: '^1.0.0',
}
const postgresReplacement: DbAdapterReplacement = {
configReplacement: [
' db: postgresAdapter({',
' pool: {',
' connectionString: process.env.DATABASE_URI,',
' },',
' }),',
],
importReplacement: "import { postgresAdapter } from '@payloadcms/db-postgres'",
packageName: '@payloadcms/db-postgres',
version: '^0.x', // up to, not including 1.0.0
}
export const dbPackages: Record<DbType, DbAdapterReplacement> = {
mongodb: mongodbReplacement,
postgres: postgresReplacement,
}
const webpackReplacement: BundlerReplacement = {
importReplacement: "import { webpackBundler } from '@payloadcms/bundler-webpack'",
packageName: '@payloadcms/bundler-webpack',
// Replacement of line containing `// bundler-config`
configReplacement: ' bundler: webpackBundler(),',
version: '^1.0.0',
}
const viteReplacement: BundlerReplacement = {
configReplacement: ' bundler: viteBundler(),',
importReplacement: "import { viteBundler } from '@payloadcms/bundler-vite'",
packageName: '@payloadcms/bundler-vite',
version: '^0.x', // up to, not including 1.0.0
}
export const bundlerPackages: Record<BundlerType, BundlerReplacement> = {
vite: viteReplacement,
webpack: webpackReplacement,
}
export const editorPackages: Record<EditorType, EditorReplacement> = {
lexical: {
configReplacement: ' editor: lexicalEditor({}),',
importReplacement: "import { lexicalEditor } from '@payloadcms/richtext-lexical'",
packageName: '@payloadcms/richtext-lexical',
version: '^0.x', // up to, not including 1.0.0
},
slate: {
configReplacement: ' editor: slateEditor({}),',
importReplacement: "import { slateEditor } from '@payloadcms/richtext-slate'",
packageName: '@payloadcms/richtext-slate',
version: '^1.0.0',
},
}

View File

@@ -0,0 +1,24 @@
import prompts from 'prompts'
import type { CliArgs } from '../types'
export async function parseProjectName(args: CliArgs): Promise<string> {
if (args['--name']) return args['--name']
if (args._[0]) return args._[0]
const response = await prompts(
{
name: 'value',
message: 'Project name?',
type: 'text',
validate: (value: string) => !!value.length,
},
{
onCancel: () => {
process.exit(0)
},
},
)
return response.value
}

View File

@@ -0,0 +1,41 @@
import prompts from 'prompts'
import type { CliArgs, ProjectTemplate } from '../types'
export async function parseTemplate(
args: CliArgs,
validTemplates: ProjectTemplate[],
): Promise<ProjectTemplate> {
if (args['--template']) {
const templateName = args['--template']
const template = validTemplates.find((t) => t.name === templateName)
if (!template) throw new Error('Invalid template given')
return template
}
const response = await prompts(
{
name: 'value',
choices: validTemplates.map((p) => {
return {
description: p.description,
title: p.name,
value: p.name,
}
}),
message: 'Choose project template',
type: 'select',
validate: (value: string) => !!value.length,
},
{
onCancel: () => {
process.exit(0)
},
},
)
const template = validTemplates.find((t) => t.name === response.value)
if (!template) throw new Error('Template is undefined')
return template
}

View File

@@ -0,0 +1,86 @@
import slugify from '@sindresorhus/slugify'
import prompts from 'prompts'
import type { CliArgs, DbDetails, DbType } from '../types'
type DbChoice = {
dbConnectionPrefix: `${string}/`
title: string
value: DbType
}
const dbChoiceRecord: Record<DbType, DbChoice> = {
mongodb: {
dbConnectionPrefix: 'mongodb://127.0.0.1/',
title: 'MongoDB',
value: 'mongodb',
},
postgres: {
dbConnectionPrefix: 'postgres://127.0.0.1:5432/',
title: 'PostgreSQL (beta)',
value: 'postgres',
},
}
export async function selectDb(args: CliArgs, projectName: string): Promise<DbDetails> {
let dbType: DbType | undefined = undefined
if (args['--db']) {
if (!Object.values(dbChoiceRecord).some((dbChoice) => dbChoice.value === args['--db'])) {
throw new Error(
`Invalid database type given. Valid types are: ${Object.values(dbChoiceRecord)
.map((dbChoice) => dbChoice.value)
.join(', ')}`,
)
}
dbType = args['--db'] as DbType
} else {
const dbTypeRes = await prompts(
{
name: 'value',
choices: Object.values(dbChoiceRecord).map((dbChoice) => {
return {
title: dbChoice.title,
value: dbChoice.value,
}
}),
message: 'Select a database',
type: 'select',
validate: (value: string) => !!value.length,
},
{
onCancel: () => {
process.exit(0)
},
},
)
dbType = dbTypeRes.value
}
const dbChoice = dbChoiceRecord[dbType]
const dbUriRes = await prompts(
{
name: 'value',
initial: `${dbChoice.dbConnectionPrefix}${
projectName === '.' ? `payload-${getRandomDigitSuffix()}` : slugify(projectName)
}`,
message: `Enter ${dbChoice.title.split(' ')[0]} connection string`, // strip beta from title
type: 'text',
validate: (value: string) => !!value.length,
},
{
onCancel: () => {
process.exit(0)
},
},
)
return {
dbUri: dbUriRes.value,
type: dbChoice.value,
}
}
function getRandomDigitSuffix(): string {
return (Math.random() * Math.pow(10, 6)).toFixed(0)
}

View File

@@ -0,0 +1,54 @@
import type { ProjectTemplate } from '../types'
import { error, info } from '../utils/log'
export function validateTemplate(templateName: string): boolean {
const validTemplates = getValidTemplates()
if (!validTemplates.map((t) => t.name).includes(templateName)) {
error(`'${templateName}' is not a valid template.`)
info(`Valid templates: ${validTemplates.map((t) => t.name).join(', ')}`)
return false
}
return true
}
export function getValidTemplates(): ProjectTemplate[] {
return [
{
name: 'blank',
description: 'Blank Template',
type: 'starter',
url: 'https://github.com/payloadcms/payload/templates/blank',
},
{
name: 'website',
description: 'Website Template',
type: 'starter',
url: 'https://github.com/payloadcms/payload/templates/website',
},
{
name: 'ecommerce',
description: 'E-commerce Template',
type: 'starter',
url: 'https://github.com/payloadcms/payload/templates/ecommerce',
},
{
name: 'plugin',
description: 'Template for creating a Payload plugin',
type: 'plugin',
url: 'https://github.com/payloadcms/payload-plugin-template',
},
{
name: 'payload-demo',
description: 'Payload demo site at https://demo.payloadcms.com',
type: 'starter',
url: 'https://github.com/payloadcms/public-demo',
},
{
name: 'payload-website',
description: 'Payload website CMS at https://payloadcms.com',
type: 'starter',
url: 'https://github.com/payloadcms/website-cms',
},
]
}

View File

@@ -0,0 +1,55 @@
import fs from 'fs-extra'
import path from 'path'
import type { ProjectTemplate } from '../types'
import { error, success } from '../utils/log'
/** Parse and swap .env.example values and write .env */
export async function writeEnvFile(args: {
databaseUri: string
payloadSecret: string
projectDir: string
template: ProjectTemplate
}): Promise<void> {
const { databaseUri, payloadSecret, projectDir, template } = args
try {
if (template.type === 'starter' && fs.existsSync(path.join(projectDir, '.env.example'))) {
// Parse .env file into key/value pairs
const envFile = await fs.readFile(path.join(projectDir, '.env.example'), 'utf8')
const envWithValues: string[] = envFile
.split('\n')
.filter((e) => e)
.map((line) => {
if (line.startsWith('#') || !line.includes('=')) return line
const split = line.split('=')
const key = split[0]
let value = split[1]
if (key === 'MONGODB_URI' || key === 'MONGO_URL' || key === 'DATABASE_URI') {
value = databaseUri
}
if (key === 'PAYLOAD_SECRET' || key === 'PAYLOAD_SECRET_KEY') {
value = payloadSecret
}
return `${key}=${value}`
})
// Write new .env file
await fs.writeFile(path.join(projectDir, '.env'), envWithValues.join('\n'))
} else {
const content = `MONGODB_URI=${databaseUri}\nPAYLOAD_SECRET=${payloadSecret}`
await fs.outputFile(`${projectDir}/.env`, content)
}
success('.env file created')
} catch (err: unknown) {
error('Unable to write .env file')
if (err instanceof Error) {
error(err.message)
}
process.exit(1)
}
}

View File

@@ -0,0 +1,133 @@
import slugify from '@sindresorhus/slugify'
import arg from 'arg'
import commandExists from 'command-exists'
import type { CliArgs, PackageManager } from './types'
import { createProject } from './lib/create-project'
import { generateSecret } from './lib/generate-secret'
import { parseProjectName } from './lib/parse-project-name'
import { parseTemplate } from './lib/parse-template'
import { selectDb } from './lib/select-db'
import { getValidTemplates, validateTemplate } from './lib/templates'
import { writeEnvFile } from './lib/write-env-file'
import { success } from './utils/log'
import { helpMessage, successMessage, welcomeMessage } from './utils/messages'
export class Main {
args: CliArgs
constructor() {
// @ts-expect-error bad typings
this.args = arg(
{
'--db': String,
'--help': Boolean,
'--name': String,
'--secret': String,
'--template': String,
// Package manager
'--no-deps': Boolean,
'--use-npm': Boolean,
'--use-pnpm': Boolean,
'--use-yarn': Boolean,
// Flags
'--beta': Boolean,
'--dry-run': Boolean,
// Aliases
'-d': '--db',
'-h': '--help',
'-n': '--name',
'-t': '--template',
},
{ permissive: true },
)
}
async init(): Promise<void> {
try {
if (this.args['--help']) {
console.log(helpMessage())
process.exit(0)
}
const templateArg = this.args['--template']
if (templateArg) {
const valid = validateTemplate(templateArg)
if (!valid) {
console.log(helpMessage())
process.exit(1)
}
}
console.log(welcomeMessage)
const projectName = await parseProjectName(this.args)
const validTemplates = getValidTemplates()
const template = await parseTemplate(this.args, validTemplates)
const projectDir = projectName === '.' ? process.cwd() : `./${slugify(projectName)}`
const packageManager = await getPackageManager(this.args)
if (template.type !== 'plugin') {
const dbDetails = await selectDb(this.args, projectName)
const payloadSecret = generateSecret()
if (!this.args['--dry-run']) {
await createProject({
cliArgs: this.args,
dbDetails,
packageManager,
projectDir,
projectName,
template,
})
await writeEnvFile({
databaseUri: dbDetails.dbUri,
payloadSecret,
projectDir,
template,
})
}
} else {
if (!this.args['--dry-run']) {
await createProject({
cliArgs: this.args,
packageManager,
projectDir,
projectName,
template,
})
}
}
success('Payload project successfully created')
console.log(successMessage(projectDir, packageManager))
} catch (error: unknown) {
console.log(error)
}
}
}
async function getPackageManager(args: CliArgs): Promise<PackageManager> {
let packageManager: PackageManager = 'npm'
if (args['--use-npm']) {
packageManager = 'npm'
} else if (args['--use-yarn']) {
packageManager = 'yarn'
} else if (args['--use-pnpm']) {
packageManager = 'pnpm'
} else {
try {
if (await commandExists('yarn')) {
packageManager = 'yarn'
} else if (await commandExists('pnpm')) {
packageManager = 'pnpm'
}
} catch (error: unknown) {
packageManager = 'npm'
}
}
return packageManager
}

View File

@@ -0,0 +1,58 @@
import type arg from 'arg'
export interface Args extends arg.Spec {
'--beta': BooleanConstructor
'--db': StringConstructor
'--dry-run': BooleanConstructor
'--help': BooleanConstructor
'--name': StringConstructor
'--no-deps': BooleanConstructor
'--secret': StringConstructor
'--template': StringConstructor
'--use-npm': BooleanConstructor
'--use-pnpm': BooleanConstructor
'--use-yarn': BooleanConstructor
'-h': string
'-n': string
'-t': string
}
export type CliArgs = arg.Result<Args>
export type ProjectTemplate = GitTemplate | PluginTemplate
/**
* Template that is cloned verbatim from a git repo
* Performs .env manipulation based upon input
*/
export interface GitTemplate extends Template {
type: 'starter'
url: string
}
/**
* Type specifically for the plugin template
* No .env manipulation is done
*/
export interface PluginTemplate extends Template {
type: 'plugin'
url: string
}
interface Template {
description?: string
name: string
type: ProjectTemplate['type']
}
export type PackageManager = 'npm' | 'pnpm' | 'yarn'
export type DbType = 'mongodb' | 'postgres'
export type DbDetails = {
dbUri: string
type: DbType
}
export type BundlerType = 'vite' | 'webpack'
export type EditorType = 'lexical' | 'slate'

View File

@@ -0,0 +1,18 @@
import chalk from 'chalk'
import figures from 'figures'
export const success = (message: string): void => {
console.log(`${chalk.green(figures.tick)} ${chalk.bold(message)}`)
}
export const warning = (message: string): void => {
console.log(chalk.yellow('? ') + chalk.bold(message))
}
export const info = (message: string): void => {
console.log(`${chalk.yellow(figures.info)} ${chalk.bold(message)}`)
}
export const error = (message: string): void => {
console.log(`${chalk.red(figures.cross)} ${chalk.bold(message)}`)
}

View File

@@ -0,0 +1,76 @@
import chalk from 'chalk'
import figures from 'figures'
import path from 'path'
import terminalLink from 'terminal-link'
import type { ProjectTemplate } from '../types'
import { getValidTemplates } from '../lib/templates'
const header = (message: string): string => `${chalk.yellow(figures.star)} ${chalk.bold(message)}`
export const welcomeMessage = chalk`
{green Welcome to Payload. Let's create a project! }
`
const spacer = ' '.repeat(8)
export function helpMessage(): string {
const validTemplates = getValidTemplates()
return chalk`
{bold USAGE}
{dim $} {bold npx create-payload-app}
{dim $} {bold npx create-payload-app} my-project
{dim $} {bold npx create-payload-app} -n my-project -t blog
{bold OPTIONS}
-n {underline my-payload-app} Set project name
-t {underline template_name} Choose specific template
{dim Available templates: ${formatTemplates(validTemplates)}}
--use-npm Use npm to install dependencies
--use-yarn Use yarn to install dependencies
--use-pnpm Use pnpm to install dependencies
--no-deps Do not install any dependencies
-h Show help
`
}
function formatTemplates(templates: ProjectTemplate[]) {
return `\n\n${spacer}${templates
.map((t) => `${t.name}${' '.repeat(28 - t.name.length)}${t.description}`)
.join(`\n${spacer}`)}`
}
export function successMessage(projectDir: string, packageManager: string): string {
return `
${header('Launch Application:')}
- cd ${projectDir}
- ${
packageManager === 'yarn' ? 'yarn' : 'npm run'
} dev or follow directions in ${createTerminalLink(
'README.md',
`file://${path.resolve(projectDir, 'README.md')}`,
)}
${header('Documentation:')}
- ${createTerminalLink(
'Getting Started',
'https://payloadcms.com/docs/getting-started/what-is-payload',
)}
- ${createTerminalLink('Configuration', 'https://payloadcms.com/docs/configuration/overview')}
`
}
// Create terminalLink with fallback for unsupported terminals
function createTerminalLink(text: string, url: string) {
return terminalLink(text, url, {
fallback: (text, url) => `${text}: ${url}`,
})
}

View File

@@ -0,0 +1,24 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true, // Make sure typescript knows that this module depends on their references
"noEmit": false /* Do not emit outputs. */,
"emitDeclarationOnly": true,
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
"rootDir": "./src" /* Specify the root folder within your source files. */
},
"exclude": [
"dist",
"build",
"tests",
"test",
"node_modules",
".eslintrc.js",
"src/**/*.spec.js",
"src/**/*.spec.jsx",
"src/**/*.spec.ts",
"src/**/*.spec.tsx"
],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
"references": [{ "path": "../payload" }]
}

View File

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

View File

@@ -150,7 +150,10 @@ export const findMany = async function find({
const countResult = await chainMethods({
methods: selectCountMethods,
query: db
.select({ count: sql<number>`count(*)` })
.select({
count: sql<number>`count
(*)`,
})
.from(table)
.where(where),
})

View File

@@ -2,7 +2,7 @@
import type { SQL } from 'drizzle-orm'
import type { Field, Operator, Where } from 'payload/types'
import { and, ilike, isNotNull, isNull, ne, or, sql } from 'drizzle-orm'
import { and, ilike, isNotNull, isNull, ne, notInArray, or, sql } from 'drizzle-orm'
import { QueryError } from 'payload/errors'
import { validOperators } from 'payload/types'
@@ -147,6 +147,7 @@ export async function parseParams({
const { operator: queryOperator, value: queryValue } = sanitizeQueryValue({
field,
operator,
relationOrPath,
val,
})
@@ -158,6 +159,17 @@ export async function parseParams({
ne<any>(rawColumn || table[columnName], queryValue),
),
)
} else if (
(field.type === 'relationship' || field.type === 'upload') &&
Array.isArray(queryValue) &&
operator === 'not_in'
) {
constraints.push(
sql`${notInArray(table[columnName], queryValue)} OR
${table[columnName]}
IS
NULL`,
)
} else {
constraints.push(
operatorMap[queryOperator](rawColumn || table[columnName], queryValue),

View File

@@ -5,12 +5,14 @@ import { createArrayFromCommaDelineated } from 'payload/utilities'
type SanitizeQueryValueArgs = {
field: Field | TabAsField
operator: string
relationOrPath: string
val: any
}
export const sanitizeQueryValue = ({
field,
operator: operatorArg,
relationOrPath,
val,
}: SanitizeQueryValueArgs): { operator: string; value: unknown } => {
let operator = operatorArg
@@ -18,6 +20,22 @@ export const sanitizeQueryValue = ({
if (!fieldAffectsData(field)) return { operator, value: formattedValue }
if (
(field.type === 'relationship' || field.type === 'upload') &&
!relationOrPath.endsWith('relationTo') &&
Array.isArray(formattedValue)
) {
const allPossibleIDTypes: (number | string)[] = []
formattedValue.forEach((val) => {
if (typeof val === 'string') {
allPossibleIDTypes.push(val, parseInt(val))
} else {
allPossibleIDTypes.push(val, String(val))
}
})
formattedValue = allPossibleIDTypes
}
// Cast incoming values as proper searchable types
if (field.type === 'checkbox' && typeof val === 'string') {
if (val.toLowerCase() === 'true') formattedValue = true

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "2.0.4",
"version": "2.0.5",
"description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "MIT",
"main": "./dist/index.js",
@@ -208,8 +208,7 @@
"webpack": "^5.78.0"
},
"engines": {
"node": ">=14",
"pnpm": ">=8"
"node": ">=14"
},
"files": [
"bin.js",

View File

@@ -55,24 +55,6 @@ export const DocumentControls: React.FC<{
const { i18n, t } = useTranslation('general')
let showPreviewButton = false
if (collection) {
showPreviewButton =
isEditing &&
collection?.admin?.preview &&
collection?.versions?.drafts &&
!collection?.versions?.drafts?.autosave
}
if (global) {
showPreviewButton =
isEditing &&
global?.admin?.preview &&
global?.versions?.drafts &&
!global?.versions?.drafts?.autosave
}
const showDotMenu = Boolean(collection && id && !disableActions)
return (
@@ -165,9 +147,12 @@ 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}
CustomComponent={
collection?.admin?.components?.edit?.PreviewButton ||
global?.admin?.components?.elements?.PreviewButton
}
generatePreviewURL={collection?.admin?.preview || global?.admin?.preview}
/>
)}
@@ -178,13 +163,26 @@ export const DocumentControls: React.FC<{
{((collection?.versions?.drafts && !collection?.versions?.drafts?.autosave) ||
(global?.versions?.drafts && !global?.versions?.drafts?.autosave)) && (
<SaveDraft
CustomComponent={collection?.admin?.components?.edit?.SaveDraftButton}
CustomComponent={
collection?.admin?.components?.edit?.SaveDraftButton ||
global?.admin?.components?.elements?.SaveDraftButton
}
/>
)}
<Publish CustomComponent={collection?.admin?.components?.edit?.PublishButton} />
<Publish
CustomComponent={
collection?.admin?.components?.edit?.PublishButton ||
global?.admin?.components?.elements?.PublishButton
}
/>
</React.Fragment>
) : (
<Save CustomComponent={collection?.admin?.components?.edit?.SaveButton} />
<Save
CustomComponent={
collection?.admin?.components?.edit?.SaveButton ||
global?.admin?.components?.elements?.SaveButton
}
/>
)}
</React.Fragment>
)}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,24 +2,24 @@ import type { PayloadRequest } from '../../../../../express/types'
import type { RichTextField, Validate } from '../../../../../fields/config/types'
import type { CellComponentProps } from '../../../views/collections/List/Cell/types'
export type RichTextFieldProps<AdapterProps = object> = Omit<
RichTextField<AdapterProps>,
export type RichTextFieldProps<Value extends object, AdapterProps> = Omit<
RichTextField<Value, AdapterProps>,
'type'
> & {
path?: string
}
export type RichTextAdapter<AdapterProps = object> = {
CellComponent: React.FC<CellComponentProps<RichTextField<AdapterProps>>>
FieldComponent: React.FC<RichTextFieldProps<AdapterProps>>
export type RichTextAdapter<Value extends object = object, AdapterProps = any> = {
CellComponent: React.FC<CellComponentProps<RichTextField<Value, AdapterProps>>>
FieldComponent: React.FC<RichTextFieldProps<Value, AdapterProps>>
afterReadPromise?: (data: {
currentDepth?: number
depth: number
field: RichTextField<AdapterProps>
field: RichTextField<Value, AdapterProps>
overrideAccess?: boolean
req: PayloadRequest
showHiddenFields: boolean
siblingDoc: Record<string, unknown>
}) => Promise<void> | null
validate: Validate<unknown, unknown, RichTextField<AdapterProps>>
validate: Validate<Value, Value, unknown, RichTextField<Value, AdapterProps>>
}

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import type { SizeReducerAction } from './sizeReducer'
export interface LivePreviewContextType {
breakpoint: LivePreviewConfig['breakpoints'][number]['name']
breakpoints: LivePreviewConfig['breakpoints']
deviceFrameRef: React.RefObject<HTMLDivElement>
iframeHasLoaded: boolean
iframeRef: React.RefObject<HTMLIFrameElement>
measuredDeviceSize: {
@@ -18,6 +17,7 @@ export interface LivePreviewContextType {
setBreakpoint: (breakpoint: LivePreviewConfig['breakpoints'][number]['name']) => void
setHeight: (height: number) => void
setIframeHasLoaded: (loaded: boolean) => void
setMeasuredDeviceSize: (size: { height: number; width: number }) => void
setSize: Dispatch<SizeReducerAction>
setToolbarPosition: (position: { x: number; y: number }) => void
setWidth: (width: number) => void
@@ -36,7 +36,6 @@ export interface LivePreviewContextType {
export const LivePreviewContext = createContext<LivePreviewContextType>({
breakpoint: undefined,
breakpoints: undefined,
deviceFrameRef: undefined,
iframeHasLoaded: false,
iframeRef: undefined,
measuredDeviceSize: {
@@ -46,6 +45,7 @@ export const LivePreviewContext = createContext<LivePreviewContextType>({
setBreakpoint: () => {},
setHeight: () => {},
setIframeHasLoaded: () => {},
setMeasuredDeviceSize: () => {},
setSize: () => {},
setToolbarPosition: () => {},
setWidth: () => {},

View File

@@ -5,7 +5,6 @@ import type { LivePreviewConfig } from '../../../../../exports/config'
import type { EditViewProps } from '../../types'
import type { usePopupWindow } from '../usePopupWindow'
import { useResize } from '../../../../utilities/useResize'
import { customCollisionDetection } from './collisionDetection'
import { LivePreviewContext } from './context'
import { sizeReducer } from './sizeReducer'
@@ -26,8 +25,6 @@ export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
const iframeRef = React.useRef<HTMLIFrameElement>(null)
const deviceFrameRef = React.useRef<HTMLDivElement>(null)
const [iframeHasLoaded, setIframeHasLoaded] = React.useState(false)
const [zoom, setZoom] = React.useState(1)
@@ -36,6 +33,11 @@ export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
const [size, setSize] = React.useReducer(sizeReducer, { height: 0, width: 0 })
const [measuredDeviceSize, setMeasuredDeviceSize] = React.useState({
height: 0,
width: 0,
})
const [breakpoint, setBreakpoint] =
React.useState<LivePreviewConfig['breakpoints'][0]['name']>('responsive')
@@ -92,22 +94,18 @@ export const LivePreviewProvider: React.FC<ToolbarProviderProps> = (props) => {
}
}, [breakpoint, breakpoints])
// keep an accurate measurement of the actual device size as it is truly rendered
// this is helpful when `sizes` are non-number units like percentages, etc.
const { size: measuredDeviceSize } = useResize(deviceFrameRef)
return (
<LivePreviewContext.Provider
value={{
breakpoint,
breakpoints,
deviceFrameRef,
iframeHasLoaded,
iframeRef,
measuredDeviceSize,
setBreakpoint,
setHeight,
setIframeHasLoaded,
setMeasuredDeviceSize,
setSize,
setToolbarPosition: setPosition,
setWidth,

View File

@@ -1,5 +1,6 @@
import React from 'react'
import React, { useEffect } from 'react'
import { useResize } from '../../../../utilities/useResize'
import { useLivePreviewContext } from '../Context/context'
export const DeviceContainer: React.FC<{
@@ -7,7 +8,22 @@ export const DeviceContainer: React.FC<{
}> = (props) => {
const { children } = props
const { breakpoint, deviceFrameRef, size, zoom } = useLivePreviewContext()
const deviceFrameRef = React.useRef<HTMLDivElement>(null)
const { breakpoint, setMeasuredDeviceSize, size, zoom } = useLivePreviewContext()
// Keep an accurate measurement of the actual device size as it is truly rendered
// This is helpful when `sizes` are non-number units like percentages, etc.
const { size: measuredDeviceSize } = useResize(deviceFrameRef)
// Sync the measured device size with the context so that other components can use it
// This happens from the bottom up so that as this component mounts and unmounts,
// Its size is freshly populated again upon re-mounting, i.e. going from iframe->popup->iframe
useEffect(() => {
if (measuredDeviceSize) {
setMeasuredDeviceSize(measuredDeviceSize)
}
}, [measuredDeviceSize, setMeasuredDeviceSize])
let x = '0'
let margin = '0'

View File

@@ -7,7 +7,7 @@ export const DeviceContainer: React.FC<{
}> = (props) => {
const { children } = props
const { breakpoint, breakpoints, deviceFrameRef, size, zoom } = useLivePreviewContext()
const { breakpoint, breakpoints, size, zoom } = useLivePreviewContext()
const foundBreakpoint = breakpoint && breakpoints?.find((bp) => bp.name === breakpoint)
@@ -31,7 +31,6 @@ export const DeviceContainer: React.FC<{
return (
<div
ref={deviceFrameRef}
style={{
height:
foundBreakpoint && foundBreakpoint?.name !== 'responsive'

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import { initTransaction } from '../../utilities/initTransaction'
import { killTransaction } from '../../utilities/killTransaction'
import { buildVersionCollectionFields } from '../../versions/buildCollectionFields'
import { appendVersionToQueryKey } from '../../versions/drafts/appendVersionToQueryKey'
import { getQueryDraftsSort } from '../../versions/drafts/getQueryDraftsSort'
import { buildAfterOperation } from './utils'
export type Arguments = {
@@ -127,7 +128,7 @@ async function find<T extends TypeWithID & Record<string, unknown>>(
page: sanitizedPage,
pagination: usePagination,
req,
sort,
sort: getQueryDraftsSort(sort),
where: fullWhere,
})
} else {

View File

@@ -517,7 +517,7 @@ export type Config = {
*/
defaultMaxTextLength?: number
/** Default richtext editor to use for richText fields */
editor: RichTextAdapter
editor: RichTextAdapter<any, any>
/**
* Email configuration options. This value is overridden by `email` in Payload.init if passed.
*

View File

@@ -398,11 +398,13 @@ export type RelationshipValue =
| ValueWithRelation[]
| (number | string)
export type RichTextField<AdapterProps = object> = FieldBase & {
type IsAny<T> = 0 extends 1 & T ? true : false
export type RichTextField<Value extends object = any, AdapterProps = any> = FieldBase & {
admin?: Admin
editor?: RichTextAdapter<AdapterProps>
editor?: RichTextAdapter<Value, AdapterProps>
type: 'richText'
} & AdapterProps
} & (IsAny<AdapterProps> extends true ? {} : AdapterProps)
export type ArrayField = FieldBase & {
admin?: Admin & {

View File

@@ -211,7 +211,7 @@ export const date: Validate<unknown, unknown, DateField> = (value, { required, t
return true
}
export const richText: Validate<unknown, unknown, RichTextField, RichTextField> = async (
export const richText: Validate<object, unknown, RichTextField, RichTextField> = async (
value,
options,
) => {
@@ -382,7 +382,7 @@ export const relationship: Validate<unknown, unknown, RelationshipField> = async
})
if (invalidRelationships.length > 0) {
return `This field has the following invalid selections: ${invalidRelationships
return `This relationship field has the following invalid relationships: ${invalidRelationships
.map((err, invalid) => {
return `${err} ${JSON.stringify(invalid)}`
})

View File

@@ -146,7 +146,7 @@
"addFilter": "Добави филтър",
"adminTheme": "Цветова тема",
"and": "И",
"applyChanges": "Приложете промените",
"applyChanges": "Приложи промените",
"ascending": "Възходящ",
"automatic": "Автоматична",
"backToDashboard": "Обратно към таблото",
@@ -176,7 +176,7 @@
"deletedSuccessfully": "Изтрито успешно.",
"deleting": "Изтриване...",
"descending": "Низходящо",
"deselectAllRows": "Деселектирайте всички редове",
"deselectAllRows": "Деселектирай всички редове",
"duplicate": "Дупликирай",
"duplicateWithoutSaving": "Дупликирай без да запазваш промените",
"edit": "Редактирай",
@@ -231,7 +231,7 @@
"saving": "Запазване...",
"searchBy": "Търси по {{label}}",
"selectAll": "Избери всички {{count}} {{label}}",
"selectAllRows": "Изберете всички редове",
"selectAllRows": "Избери всички редове",
"selectValue": "Избери стойност",
"selectedCount": "{{count}} {{label}} избрани",
"showAllLabel": "Покажи всички {{label}}",
@@ -273,23 +273,23 @@
"near": "близко"
},
"upload": {
"crop": "Реколта",
"cropToolDescription": "Плъзнете ъглите на избраната област, нарисувайте нова област или коригирайте стойностите по-долу.",
"crop": "Изрязване",
"cropToolDescription": "Плъзни ъглите на избраната област, избери нова област или коригирай стойностите по-долу.",
"dragAndDrop": "Дръпни и пусни файл",
"dragAndDropHere": "или дръпни и пусни файла тук",
"editImage": "Редактирай изображение",
"fileName": "Име на файла",
"fileSize": "Големина на файла",
"focalPoint": "Фокусна точка",
"focalPointDescription": "Преместете фокусната точка директно върху визуализацията или регулирайте стойностите по-долу.",
"focalPointDescription": "Премести фокусната точка директно върху визуализацията или регулирай стойностите по-долу.",
"height": "Височина",
"lessInfo": "По-малко информация",
"moreInfo": "Повече информация",
"previewSizes": "Преглед на размери",
"selectCollectionToBrowse": "Избери колекция, която да разгледаш",
"selectFile": "Избери файл",
"setCropArea": "Задайте област за изрязване",
"setFocalPoint": "Задайте фокусна точка",
"setCropArea": "Задай област за изрязване",
"setFocalPoint": "Задай фокусна точка",
"sizes": "Големини",
"sizesFor": "Размери за {{label}}",
"width": "Ширина"

View File

@@ -0,0 +1,17 @@
/**
* Takes the incoming sort argument and prefixes it with `versions.` and preserves any `-` prefixes for descending order
* @param sort
*/
export const getQueryDraftsSort = (sort: string): string => {
if (!sort) return sort
let direction = ''
let orderBy = sort
if (sort[0] === '-') {
direction = '-'
orderBy = sort.substring(1)
}
return `${direction}version.${orderBy}`
}

View File

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

View File

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

View File

@@ -89,9 +89,16 @@ const RichText: React.FC<FieldProps> = (props) => {
fieldProps={props}
initialState={initialValue}
onChange={(editorState, editor, tags) => {
const json = editorState.toJSON()
let serializedEditorState = editorState.toJSON()
setValue(json)
// Transform state through save hooks
if (editorConfig?.features?.hooks?.save?.length) {
editorConfig.features.hooks.save.forEach((hook) => {
serializedEditorState = hook({ incomingEditorState: serializedEditorState })
})
}
setValue(serializedEditorState)
}}
readOnly={readOnly}
setValue={setValue}
@@ -109,7 +116,7 @@ function fallbackRender({ error }): JSX.Element {
// Call resetErrorBoundary() to reset the error boundary and retry the render.
return (
<div role="alert">
<div className="errorBoundary" role="alert">
<p>Something went wrong:</p>
<pre style={{ color: 'red' }}>{error.message}</pre>
</div>

View File

@@ -1,3 +1,5 @@
import type { Block } from 'payload/types'
import { sanitizeFields } from 'payload/config'
import type { BlocksFeatureProps } from '.'
@@ -20,39 +22,41 @@ 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) {
// 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: node.fields.data || {},
data: blockFieldData,
depth,
fields: block.fields,
overrideAccess,
promises,
req,
showHiddenFields,
siblingDoc,
// The afterReadPromise gets its data from looking for field.name inside of the siblingDoc. Thus, here we cannot pass the whole document's siblingDoc, but only the siblingDoc (sibling fields) of the current field.
siblingDoc: blockFieldData,
})
}
})
}
return promises
}

View File

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

View File

@@ -1,14 +1,20 @@
import { type ElementFormatType } from 'lexical'
import { Form, buildInitialState, useFormSubmitted } from 'payload/components/forms'
import React, { useMemo } from 'react'
import React, { useEffect, useMemo } from 'react'
import { type BlockFields } from '../nodes/BlocksNode'
const baseClass = 'lexical-block'
import type { Data } from 'payload/types'
import { useConfig } from 'payload/components/utilities'
import {
buildStateFromSchema,
useConfig,
useDocumentInfo,
useLocale,
} from 'payload/components/utilities'
import { sanitizeFields } from 'payload/config'
import { useTranslation } from 'react-i18next'
import type { BlocksFeatureProps } from '..'
@@ -43,13 +49,49 @@ export const BlockComponent: React.FC<Props> = (props) => {
validRelationships,
})
const initialDataRef = React.useRef<Data>(buildInitialState(fields.data || {})) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
const initialStateRef = React.useRef<Data>(buildInitialState(fields.data || {})) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
const config = useConfig()
const { t } = useTranslation('general')
const { code: locale } = useLocale()
const { getDocPreferences } = useDocumentInfo()
// initialState State
const [initialState, setInitialState] = React.useState<Data>(null)
useEffect(() => {
async function buildInitialState() {
const preferences = await getDocPreferences()
const stateFromSchema = await buildStateFromSchema({
config,
data: fields.data,
fieldSchema: block.fields,
locale,
operation: 'update',
preferences,
t,
})
// We have to merge the output of buildInitialState (above this useEffect) with the output of buildStateFromSchema.
// That's because the output of buildInitialState provides important properties necessary for THIS block,
// like blockName, blockType and id, while buildStateFromSchema provides the correct output of this block's data,
// e.g. if this block has a sub-block (like the `rows` property)
setInitialState({
...initialStateRef?.current,
...stateFromSchema,
})
}
void buildInitialState()
}, [setInitialState, config, block, locale, getDocPreferences, t]) // do not add fields here, it causes an endless loop
// Memoized Form JSX
const formContent = useMemo(() => {
return (
block && (
<Form initialState={initialDataRef?.current} submitted={submitted}>
block &&
initialState && (
<Form fields={block.fields} initialState={initialState} submitted={submitted}>
<BlockContent
baseClass={baseClass}
block={block}
@@ -60,7 +102,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
</Form>
)
)
}, [block, field, nodeKey, submitted])
}, [block, field, nodeKey, submitted, initialState])
return <div className={baseClass}>{formContent}</div>
}

View File

@@ -15,12 +15,12 @@ export const blockValidationHOC = (
payloadConfig,
validation,
}) => {
const blockFieldValues = node.fields.data
const blockFieldData = node.fields.data
const blocks: Block[] = props.blocks
// Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here
blocks.forEach((block) => {
const validRelationships = payloadConfig.collections.map((c) => c.slug) || []
blocks.forEach((block) => {
block.fields = sanitizeFields({
config: payloadConfig,
fields: block.fields,
@@ -29,7 +29,7 @@ export const blockValidationHOC = (
})
// find block
const block = props.blocks.find((block) => block.slug === blockFieldValues.blockType)
const block = props.blocks.find((block) => block.slug === blockFieldData.blockType)
// validate block
if (!block) {

View File

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

View File

@@ -1,5 +1,5 @@
import type { LexicalCommand } from 'lexical'
import type { Fields } from 'payload/types'
import type { Data, Fields } from 'payload/types'
import { useModal } from '@faceless-ui/modal'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
@@ -14,7 +14,6 @@ import {
createCommand,
} from 'lexical'
import { formatDrawerSlug } from 'payload/components/elements'
import { reduceFieldsToValues } from 'payload/components/forms'
import {
buildStateFromSchema,
useAuth,
@@ -317,17 +316,14 @@ export function LinkEditor({
<LinkDrawer
drawerSlug={drawerSlug}
fieldSchema={fieldSchema}
handleModalSubmit={(fields: Fields) => {
handleModalSubmit={(fields: Fields, data: Data) => {
closeModal(drawerSlug)
const data = reduceFieldsToValues(fields, true)
if (data?.fields?.doc?.value) {
data.fields.doc.value = {
id: data.fields.doc.value,
}
}
const newLinkPayload: LinkPayload = data as LinkPayload
editor.dispatchCommand(TOGGLE_LINK_COMMAND, newLinkPayload)

View File

@@ -16,7 +16,6 @@ html[data-theme='light'] {
position: absolute;
top: 0;
left: 0;
z-index: 10;
opacity: 0;
border-radius: 6.25px;
transition: opacity 0.2s;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ export type AfterReadPromise<T extends SerializedLexicalNode = SerializedLexical
afterReadPromises: Map<string, Array<AfterReadPromise>>
currentDepth: number
depth: number
field: RichTextField<AdapterProps>
field: RichTextField<SerializedEditorState, AdapterProps>
node: T
overrideAccess: boolean
req: PayloadRequest
@@ -51,6 +51,18 @@ export type Feature = {
floatingSelectToolbar?: {
sections: FloatingToolbarSection[]
}
hooks?: {
load?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
save?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
}
markdownTransformers?: Transformer[]
nodes?: Array<{
afterReadPromises?: Array<AfterReadPromise>
@@ -123,6 +135,22 @@ export type SanitizedFeatures = Required<
floatingSelectToolbar: {
sections: FloatingToolbarSection[]
}
hooks: {
load: Array<
({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
>
save: Array<
({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
>
}
plugins?: Array<
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality

View File

@@ -4,6 +4,12 @@
display: flex;
isolation: isolate;
.errorBoundary {
pre {
text-wrap: unset;
}
}
&__wrap {
width: 100%;
position: relative;

View File

@@ -87,13 +87,17 @@ export const LexicalEditor: React.FC<LexicalProviderProps> = (props) => {
return <plugin.Component anchorElem={floatingAnchorElem} key={plugin.key} />
}
})}
{editor.isEditable() && (
<React.Fragment>
<FloatingSelectToolbarPlugin anchorElem={floatingAnchorElem} />
<SlashMenuPlugin anchorElem={floatingAnchorElem} />
</React.Fragment>
)}
</React.Fragment>
)}
{editor.isEditable() && (
<React.Fragment>
<HistoryPlugin />
<FloatingSelectToolbarPlugin />
<SlashMenuPlugin />
<MarkdownShortcutPlugin />
</React.Fragment>
)}

View File

@@ -21,7 +21,31 @@ 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)) ||
(initialState && Array.isArray(initialState) && !('root' in initialState))
) {
throw new Error(
'You have tried to pass in data from the old, Slate editor, to the new, Lexical editor. This is not supported. There is no automatic conversion from Slate to Lexical data available yet (coming soon). Please remove the data from the field and start again.',
)
}
if (value && 'jsonContent' in value) {
throw new Error(
'You have tried to pass in data from payload-plugin-lexical. This is not supported. The data structure has changed in this editor, compared to the plugin, and there is no automatic conversion available yet (coming soon). Please remove the data from the field and start again.',
)
}
const initialConfig: InitialConfigType = {
editable: readOnly === true ? false : true,

View File

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

View File

@@ -99,6 +99,11 @@ export function DropDownItem({
})
}
}}
onMouseDown={(e) => {
// This is required for Firefox compatibility. Without it, the dropdown will disappear without the onClick being called.
// This only happens in Firefox. Must be something about how Firefox handles focus events differently.
e.preventDefault()
}}
ref={ref}
title={title}
type="button"

View File

@@ -15,7 +15,6 @@ import { createPortal } from 'react-dom'
import { useEditorConfigContext } from '../../config/EditorConfigProvider'
import { getDOMRangeRect } from '../../utils/getDOMRangeRect'
import { getSelectedNode } from '../../utils/getSelectedNode'
import { setFloatingElemPosition } from '../../utils/setFloatingElemPosition'
import { ToolbarButton } from './ToolbarButton'
import { ToolbarDropdown } from './ToolbarDropdown'
@@ -33,7 +32,22 @@ function FloatingSelectToolbar({
const { editorConfig } = useEditorConfigContext()
function mouseMoveListener(e: MouseEvent) {
const closeFloatingToolbar = useCallback(() => {
if (popupCharStylesEditorRef?.current) {
const isOpacityZero = popupCharStylesEditorRef.current.style.opacity === '0'
const isPointerEventsNone = popupCharStylesEditorRef.current.style.pointerEvents === 'none'
if (!isOpacityZero) {
popupCharStylesEditorRef.current.style.opacity = '0'
}
if (!isPointerEventsNone) {
popupCharStylesEditorRef.current.style.pointerEvents = 'none'
}
}
}, [popupCharStylesEditorRef])
const mouseMoveListener = useCallback(
(e: MouseEvent) => {
if (popupCharStylesEditorRef?.current && (e.buttons === 1 || e.buttons === 3)) {
const isOpacityZero = popupCharStylesEditorRef.current.style.opacity === '0'
const isPointerEventsNone = popupCharStylesEditorRef.current.style.pointerEvents === 'none'
@@ -44,17 +58,15 @@ function FloatingSelectToolbar({
const elementUnderMouse = document.elementFromPoint(x, y)
if (!popupCharStylesEditorRef.current.contains(elementUnderMouse)) {
// Mouse is not over the target element => not a normal click, but probably a drag
if (!isOpacityZero) {
popupCharStylesEditorRef.current.style.opacity = '0'
}
if (!isPointerEventsNone) {
popupCharStylesEditorRef.current.style.pointerEvents = 'none'
closeFloatingToolbar()
}
}
}
}
}
function mouseUpListener(e: MouseEvent) {
},
[closeFloatingToolbar],
)
const mouseUpListener = useCallback(() => {
if (popupCharStylesEditorRef?.current) {
if (popupCharStylesEditorRef.current.style.opacity !== '1') {
popupCharStylesEditorRef.current.style.opacity = '1'
@@ -63,7 +75,7 @@ function FloatingSelectToolbar({
popupCharStylesEditorRef.current.style.pointerEvents = 'auto'
}
}
}
}, [popupCharStylesEditorRef])
useEffect(() => {
document.addEventListener('mousemove', mouseMoveListener)
@@ -73,7 +85,7 @@ function FloatingSelectToolbar({
document.removeEventListener('mousemove', mouseMoveListener)
document.removeEventListener('mouseup', mouseUpListener)
}
}, [popupCharStylesEditorRef])
}, [popupCharStylesEditorRef, mouseMoveListener, mouseUpListener])
const updateTextFormatFloatingToolbar = useCallback(() => {
const selection = $getSelection()

View File

@@ -503,6 +503,7 @@ export function LexicalMenu({
}
export function useMenuAnchorRef(
anchorElem: HTMLElement,
resolution: MenuResolution | null,
setResolution: (r: MenuResolution | null) => void,
className?: string,
@@ -517,8 +518,11 @@ export function useMenuAnchorRef(
const menuEle = containerDiv.firstChild as Element
if (rootElement !== null && resolution !== null) {
const { height, left, top, width } = resolution.getRect()
containerDiv.style.top = `${top + window.scrollY + VERTICAL_OFFSET}px`
let { height, left, top, width } = resolution.getRect()
const rawTop = top
const rawLeft = left
top -= anchorElem.getBoundingClientRect().top + window.scrollY
left -= anchorElem.getBoundingClientRect().left + window.scrollX
containerDiv.style.left = `${left + window.scrollX}px`
containerDiv.style.height = `${height}px`
containerDiv.style.width = `${width}px`
@@ -533,19 +537,18 @@ export function useMenuAnchorRef(
containerDiv.style.left = `${rootElementRect.right - menuWidth + window.scrollX}px`
}
const wouldGoOffTopOfScreen = top < menuHeight
const wouldGoOffBottomOfContainer = top + menuHeight > rootElementRect.bottom
const wouldGoOffBottomOfScreen = rawTop + menuHeight + VERTICAL_OFFSET > window.innerHeight
//const wouldGoOffBottomOfContainer = top + menuHeight > rootElementRect.bottom
const wouldGoOffTopOfScreen = rawTop < 0
// Position slash menu above the cursor instead of below (default) if it would otherwise go off the bottom of the screen.
if (
(top + menuHeight > window.innerHeight ||
(wouldGoOffBottomOfContainer && !wouldGoOffTopOfScreen)) &&
top - rootElementRect.top > menuHeight
) {
if (wouldGoOffBottomOfScreen && !wouldGoOffTopOfScreen) {
const margin = 24
containerDiv.style.top = `${
top + VERTICAL_OFFSET - menuHeight + window.scrollY - (height + margin)
}px`
} else {
containerDiv.style.top = `${top + window.scrollY + VERTICAL_OFFSET}px`
}
}
@@ -558,12 +561,12 @@ export function useMenuAnchorRef(
containerDiv.setAttribute('role', 'listbox')
containerDiv.style.display = 'block'
containerDiv.style.position = 'absolute'
document.body.append(containerDiv)
anchorElem.append(containerDiv)
}
anchorElementRef.current = containerDiv
rootElement.setAttribute('aria-controls', 'typeahead-menu')
}
}, [editor, resolution, className])
}, [editor, resolution, className, anchorElem])
useEffect(() => {
const rootElement = editor.getRootElement()

View File

@@ -168,6 +168,7 @@ export function useBasicTypeaheadTriggerMatch(
export type TypeaheadMenuPluginProps = {
anchorClassName?: string
anchorElem: HTMLElement
groupsWithOptions: Array<SlashMenuGroup>
menuRenderFn: MenuRenderFn
onClose?: () => void
@@ -188,6 +189,7 @@ export const ENABLE_SLASH_MENU_COMMAND: LexicalCommand<{
export function LexicalTypeaheadMenuPlugin({
anchorClassName,
anchorElem,
groupsWithOptions,
menuRenderFn,
onClose,
@@ -198,7 +200,7 @@ export function LexicalTypeaheadMenuPlugin({
}: TypeaheadMenuPluginProps): JSX.Element | null {
const [editor] = useLexicalComposerContext()
const [resolution, setResolution] = useState<MenuResolution | null>(null)
const anchorElementRef = useMenuAnchorRef(resolution, setResolution, anchorClassName)
const anchorElementRef = useMenuAnchorRef(anchorElem, resolution, setResolution, anchorClassName)
const closeTypeahead = useCallback(() => {
setResolution(null)

View File

@@ -15,6 +15,8 @@ html[data-theme='light'] {
font-family: var(--font-body);
max-height: 300px;
overflow-y: scroll;
z-index: 10;
position: absolute;
.group {
padding-bottom: 8px;

View File

@@ -64,7 +64,11 @@ function SlashMenuItem({
)
}
export function SlashMenuPlugin(): JSX.Element {
export function SlashMenuPlugin({
anchorElem = document.body,
}: {
anchorElem?: HTMLElement
}): JSX.Element {
const [editor] = useLexicalComposerContext()
const [queryString, setQueryString] = useState<null | string>(null)
const { editorConfig } = useEditorConfigContext()
@@ -162,6 +166,7 @@ export function SlashMenuPlugin(): JSX.Element {
return (
<React.Fragment>
<LexicalTypeaheadMenuPlugin
anchorElem={anchorElem}
groupsWithOptions={groups}
menuRenderFn={(
anchorElementRef,

View File

@@ -46,5 +46,12 @@
top: 0;
opacity: 0;
will-change: transform;
transition: transform 0.05s;
transition: transform 0.04s;
}
/* This targets Firefox 57+. The transition looks ugly on firefox, thus we disable it here */
@-moz-document url-prefix() {
.draggable-block-target-line {
transition: none;
}
}

Some files were not shown because too many files have changed in this diff Show More