Merge branch 'main' of github.com:payloadcms/payload
This commit is contained in:
@@ -13,3 +13,6 @@ dfac7395fed95fc5d8ebca21b786ce70821942bb
|
||||
|
||||
# lint and format plugin-cloud
|
||||
fb7d1be2f3325d076b7c967b1730afcef37922c2
|
||||
|
||||
# lint and format create-payload-app
|
||||
5fd3d430001efe86515262ded5e26f00c1451181
|
||||
|
||||
2
.github/workflows/main.yml
vendored
2
.github/workflows/main.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
44
packages/create-payload-app/.eslintrc.js
Normal file
44
packages/create-payload-app/.eslintrc.js
Normal 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,
|
||||
}
|
||||
34
packages/create-payload-app/README.md
Normal file
34
packages/create-payload-app/README.md
Normal 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
|
||||
```
|
||||
2
packages/create-payload-app/bin/cli.js
Executable file
2
packages/create-payload-app/bin/cli.js
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require('../dist/index.js')
|
||||
9
packages/create-payload-app/jest.config.js
Normal file
9
packages/create-payload-app/jest.config.js
Normal 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,
|
||||
}
|
||||
47
packages/create-payload-app/package.json
Normal file
47
packages/create-payload-app/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
packages/create-payload-app/src/index.ts
Normal file
8
packages/create-payload-app/src/index.ts
Normal 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}`))
|
||||
117
packages/create-payload-app/src/lib/configure-payload-config.ts
Normal file
117
packages/create-payload-app/src/lib/configure-payload-config.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
151
packages/create-payload-app/src/lib/create-project.spec.ts
Normal file
151
packages/create-payload-app/src/lib/create-project.spec.ts
Normal 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
|
||||
})
|
||||
})
|
||||
102
packages/create-payload-app/src/lib/create-project.ts
Normal file
102
packages/create-payload-app/src/lib/create-project.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
5
packages/create-payload-app/src/lib/generate-secret.ts
Normal file
5
packages/create-payload-app/src/lib/generate-secret.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
export function generateSecret(): string {
|
||||
return randomBytes(32).toString('hex').slice(0, 24)
|
||||
}
|
||||
83
packages/create-payload-app/src/lib/packages.ts
Normal file
83
packages/create-payload-app/src/lib/packages.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
24
packages/create-payload-app/src/lib/parse-project-name.ts
Normal file
24
packages/create-payload-app/src/lib/parse-project-name.ts
Normal 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
|
||||
}
|
||||
41
packages/create-payload-app/src/lib/parse-template.ts
Normal file
41
packages/create-payload-app/src/lib/parse-template.ts
Normal 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
|
||||
}
|
||||
86
packages/create-payload-app/src/lib/select-db.ts
Normal file
86
packages/create-payload-app/src/lib/select-db.ts
Normal 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)
|
||||
}
|
||||
54
packages/create-payload-app/src/lib/templates.ts
Normal file
54
packages/create-payload-app/src/lib/templates.ts
Normal 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',
|
||||
},
|
||||
]
|
||||
}
|
||||
55
packages/create-payload-app/src/lib/write-env-file.ts
Normal file
55
packages/create-payload-app/src/lib/write-env-file.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
133
packages/create-payload-app/src/main.ts
Normal file
133
packages/create-payload-app/src/main.ts
Normal 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
|
||||
}
|
||||
58
packages/create-payload-app/src/types.ts
Normal file
58
packages/create-payload-app/src/types.ts
Normal 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'
|
||||
18
packages/create-payload-app/src/utils/log.ts
Normal file
18
packages/create-payload-app/src/utils/log.ts
Normal 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)}`)
|
||||
}
|
||||
76
packages/create-payload-app/src/utils/messages.ts
Normal file
76
packages/create-payload-app/src/utils/messages.ts
Normal 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}`,
|
||||
})
|
||||
}
|
||||
24
packages/create-payload-app/tsconfig.json
Normal file
24
packages/create-payload-app/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview-react",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "The official live preview React SDK for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "The official live preview JavaScript SDK for Payload",
|
||||
"repository": "https://github.com/payloadcms/payload",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -51,6 +51,7 @@ const Duplicate: React.FC<Props> = ({ id, collection, slug }) => {
|
||||
},
|
||||
params: {
|
||||
depth: 0,
|
||||
draft: true,
|
||||
locale,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -61,6 +61,7 @@ export const addFieldStatePromise = async ({
|
||||
user,
|
||||
value: data?.[field.name],
|
||||
})
|
||||
|
||||
if (data?.[field.name]) {
|
||||
data[field.name] = valueWithDefault
|
||||
}
|
||||
@@ -145,8 +146,8 @@ export const addFieldStatePromise = async ({
|
||||
fieldState.value = null
|
||||
fieldState.initialValue = null
|
||||
} else {
|
||||
fieldState.value = arrayValue
|
||||
fieldState.initialValue = arrayValue
|
||||
fieldState.value = arrayValue.length
|
||||
fieldState.initialValue = arrayValue.length
|
||||
|
||||
if (arrayValue.length > 0) {
|
||||
fieldState.disableFormData = true
|
||||
@@ -236,8 +237,8 @@ export const addFieldStatePromise = async ({
|
||||
fieldState.value = null
|
||||
fieldState.initialValue = null
|
||||
} else {
|
||||
fieldState.value = blocksValue
|
||||
fieldState.initialValue = blocksValue
|
||||
fieldState.value = blocksValue.length
|
||||
fieldState.initialValue = blocksValue.length
|
||||
|
||||
if (blocksValue.length > 0) {
|
||||
fieldState.disableFormData = true
|
||||
|
||||
@@ -8,6 +8,9 @@ import getSiblingData from './getSiblingData'
|
||||
import reduceFieldsToValues from './reduceFieldsToValues'
|
||||
import { flattenRows, separateRows } from './rows'
|
||||
|
||||
/**
|
||||
* Reducer which modifies the form field state (all the current data of the fields in the form). When called using dispatch, it will return a new state object.
|
||||
*/
|
||||
export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
switch (action.type) {
|
||||
case 'REPLACE_STATE': {
|
||||
@@ -123,7 +126,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
...state[path],
|
||||
disableFormData: rows.length > 0,
|
||||
rows: rowsMetadata,
|
||||
value: rows,
|
||||
value: rows.length,
|
||||
},
|
||||
...flattenRows(path, rows),
|
||||
}
|
||||
@@ -161,10 +164,6 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
const { remainingFields, rows: siblingRows } = separateRows(path, state)
|
||||
siblingRows.splice(rowIndex, 0, subFieldState)
|
||||
|
||||
// add new row to array _value_
|
||||
const currentValue = (Array.isArray(state[path]?.value) ? state[path]?.value : []) as Fields[]
|
||||
const newValue = currentValue.splice(rowIndex, 0, reduceFieldsToValues(subFieldState, true))
|
||||
|
||||
const newState: Fields = {
|
||||
...remainingFields,
|
||||
...flattenRows(path, siblingRows),
|
||||
@@ -172,7 +171,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
...state[path],
|
||||
disableFormData: true,
|
||||
rows: rowsMetadata,
|
||||
value: newValue,
|
||||
value: siblingRows.length,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -203,10 +202,6 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
// replace form _field state_
|
||||
siblingRows[rowIndex] = subFieldState
|
||||
|
||||
// replace array _value_
|
||||
const newValue = Array.isArray(state[path]?.value) ? state[path]?.value : []
|
||||
newValue[rowIndex] = reduceFieldsToValues(subFieldState, true)
|
||||
|
||||
const newState: Fields = {
|
||||
...remainingFields,
|
||||
...flattenRows(path, siblingRows),
|
||||
@@ -214,7 +209,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
...state[path],
|
||||
disableFormData: true,
|
||||
rows: rowsMetadata,
|
||||
value: newValue,
|
||||
value: siblingRows.length,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -245,7 +240,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
|
||||
...state[path],
|
||||
disableFormData: true,
|
||||
rows: rowsMetadata,
|
||||
value: rows,
|
||||
value: rows.length,
|
||||
},
|
||||
...flattenRows(path, rows),
|
||||
}
|
||||
|
||||
@@ -50,12 +50,15 @@ import reduceFieldsToValues from './reduceFieldsToValues'
|
||||
const baseClass = 'form'
|
||||
|
||||
const Form: React.FC<Props> = (props) => {
|
||||
const { id, collection, getDocPreferences, global } = useDocumentInfo()
|
||||
|
||||
const {
|
||||
action,
|
||||
children,
|
||||
className,
|
||||
disableSuccessStatus,
|
||||
disabled,
|
||||
fields: fieldsFromProps = collection?.fields || global?.fields,
|
||||
handleResponse,
|
||||
initialData, // values only, paths are required as key - form should build initial state as convenience
|
||||
initialState, // fully formed initial field state
|
||||
@@ -71,7 +74,6 @@ const Form: React.FC<Props> = (props) => {
|
||||
const { code: locale } = useLocale()
|
||||
const { i18n, t } = useTranslation('general')
|
||||
const { refreshCookie, user } = useAuth()
|
||||
const { id, collection, getDocPreferences, global } = useDocumentInfo()
|
||||
const operation = useOperation()
|
||||
|
||||
const config = useConfig()
|
||||
@@ -90,6 +92,10 @@ const Form: React.FC<Props> = (props) => {
|
||||
if (initialState) initialFieldState = initialState
|
||||
|
||||
const fieldsReducer = useReducer(fieldReducer, {}, () => initialFieldState)
|
||||
/**
|
||||
* `fields` is the current, up-to-date state/data of all fields in the form. It can be modified by using dispatchFields,
|
||||
* which calls the fieldReducer, which then updates the state.
|
||||
*/
|
||||
const [fields, dispatchFields] = fieldsReducer
|
||||
|
||||
contextRef.current.fields = fields
|
||||
@@ -167,7 +173,13 @@ const Form: React.FC<Props> = (props) => {
|
||||
let validationResult: boolean | string = true
|
||||
|
||||
if (typeof field.validate === 'function') {
|
||||
validationResult = await field.validate(field.value, {
|
||||
let valueToValidate = field.value
|
||||
|
||||
if (field?.rows && Array.isArray(field.rows)) {
|
||||
valueToValidate = contextRef.current.getDataByPath(path)
|
||||
}
|
||||
|
||||
validationResult = await field.validate(valueToValidate, {
|
||||
id,
|
||||
config,
|
||||
data,
|
||||
@@ -434,7 +446,7 @@ const Form: React.FC<Props> = (props) => {
|
||||
const getRowSchemaByPath = React.useCallback(
|
||||
({ blockType, path }: { blockType?: string; path: string }) => {
|
||||
const rowConfig = traverseRowConfigs({
|
||||
fieldConfig: collection?.fields || global?.fields,
|
||||
fieldConfig: fieldsFromProps,
|
||||
path,
|
||||
})
|
||||
const rowFieldConfigs = buildFieldSchemaMap(rowConfig)
|
||||
@@ -442,10 +454,11 @@ const Form: React.FC<Props> = (props) => {
|
||||
const fieldKey = pathSegments.at(-1)
|
||||
return rowFieldConfigs.get(blockType ? `${fieldKey}.${blockType}` : fieldKey)
|
||||
},
|
||||
[traverseRowConfigs, collection?.fields, global?.fields],
|
||||
[traverseRowConfigs, fieldsFromProps],
|
||||
)
|
||||
|
||||
// Array/Block row manipulation
|
||||
// Array/Block row manipulation. This is called when, for example, you add a new block to a blocks field.
|
||||
// The block data is saved in the rows property of the state, which is modified updated here.
|
||||
const addFieldRow: Context['addFieldRow'] = useCallback(
|
||||
async ({ data, path, rowIndex }) => {
|
||||
const preferences = await getDocPreferences()
|
||||
|
||||
@@ -2,7 +2,12 @@ import type React from 'react'
|
||||
import type { Dispatch } from 'react'
|
||||
|
||||
import type { User } from '../../../../auth/types'
|
||||
import type { Condition, Field as FieldConfig, Validate } from '../../../../fields/config/types'
|
||||
import type {
|
||||
Condition,
|
||||
Field,
|
||||
Field as FieldConfig,
|
||||
Validate,
|
||||
} from '../../../../fields/config/types'
|
||||
|
||||
export type Row = {
|
||||
blockType?: string
|
||||
@@ -41,6 +46,12 @@ export type Props = {
|
||||
className?: string
|
||||
disableSuccessStatus?: boolean
|
||||
disabled?: boolean
|
||||
/**
|
||||
* By default, the form will get the field schema (not data) from the current document. If you pass this in, you can override that behavior.
|
||||
* This is very useful for sub-forms, where the form's field schema is not necessarily the field schema of the current document (e.g. for the Blocks
|
||||
* feature of the Lexical Rich Text field)
|
||||
*/
|
||||
fields?: Field[]
|
||||
handleResponse?: (res: Response) => void
|
||||
initialData?: Data
|
||||
initialState?: Fields
|
||||
|
||||
@@ -17,10 +17,21 @@ const intersectionObserverOptions = {
|
||||
rootMargin: '1000px',
|
||||
}
|
||||
|
||||
// If you send `fields` through, it will render those fields explicitly
|
||||
// Otherwise, it will reduce your fields using the other provided props
|
||||
// This is so that we can conditionally render fields before reducing them, if desired
|
||||
// See the sidebar in '../collections/Edit/Default/index.tsx' for an example
|
||||
/**
|
||||
* If you send `fields` through, it will render those fields explicitly
|
||||
* Otherwise, it will reduce your fields using the other provided props
|
||||
* This is so that we can conditionally render fields before reducing them, if desired
|
||||
* See the sidebar in '../collections/Edit/Default/index.tsx' for an example
|
||||
*
|
||||
* The state/data for the fields it renders is not managed by this component. Instead, every component it renders has
|
||||
* their own handling of their own value, usually through the useField hook. This hook will get the field's value
|
||||
* from the Form the field is in, using the field's path.
|
||||
*
|
||||
* Thus, if you would like to set the value of a field you render here, you must do so in the Form that contains the field, or in the
|
||||
* Field component itself.
|
||||
*
|
||||
* All this component does is render the field's Field Components, and pass them the props they need to function.
|
||||
**/
|
||||
const RenderFields: React.FC<Props> = (props) => {
|
||||
const { className, fieldTypes, forceRender, margins } = props
|
||||
|
||||
|
||||
@@ -6,21 +6,23 @@ import type { ReducedField } from './filterFields'
|
||||
export type Props = {
|
||||
className?: string
|
||||
fieldTypes: FieldTypes
|
||||
margins?: 'small' | false
|
||||
forceRender?: boolean
|
||||
} & (
|
||||
| {
|
||||
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[]
|
||||
}
|
||||
)
|
||||
|
||||
@@ -91,7 +91,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
showError,
|
||||
valid,
|
||||
value,
|
||||
} = useField<[]>({
|
||||
} = useField<number>({
|
||||
condition,
|
||||
hasRows: true,
|
||||
path,
|
||||
@@ -123,8 +123,8 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
)
|
||||
|
||||
const removeRow = useCallback(
|
||||
async (rowIndex: number) => {
|
||||
await removeFieldRow({ path, rowIndex })
|
||||
(rowIndex: number) => {
|
||||
removeFieldRow({ path, rowIndex })
|
||||
setModified(true)
|
||||
},
|
||||
[removeFieldRow, path, setModified],
|
||||
@@ -278,7 +278,7 @@ const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
icon="plus"
|
||||
iconPosition="left"
|
||||
iconStyle="with-border"
|
||||
onClick={() => addRow(value?.length || 0)}
|
||||
onClick={() => addRow(value || 0)}
|
||||
>
|
||||
{t('addLabel', { label: getTranslation(labels.singular, i18n) })}
|
||||
</Button>
|
||||
|
||||
@@ -90,7 +90,7 @@ const BlocksField: React.FC<Props> = (props) => {
|
||||
showError,
|
||||
valid,
|
||||
value,
|
||||
} = useField<[]>({
|
||||
} = useField<number>({
|
||||
condition,
|
||||
hasRows: true,
|
||||
path,
|
||||
@@ -128,8 +128,8 @@ const BlocksField: React.FC<Props> = (props) => {
|
||||
)
|
||||
|
||||
const removeRow = useCallback(
|
||||
async (rowIndex: number) => {
|
||||
await removeFieldRow({ path, rowIndex })
|
||||
(rowIndex: number) => {
|
||||
removeFieldRow({ path, rowIndex })
|
||||
setModified(true)
|
||||
},
|
||||
[path, removeFieldRow, setModified],
|
||||
@@ -297,7 +297,7 @@ const BlocksField: React.FC<Props> = (props) => {
|
||||
</DrawerToggler>
|
||||
<BlocksDrawer
|
||||
addRow={addRow}
|
||||
addRowIndex={value?.length || 0}
|
||||
addRowIndex={value || 0}
|
||||
blocks={blocks}
|
||||
drawerSlug={drawerSlug}
|
||||
labels={labels}
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
|
||||
const dispatchField = useFormFields(([_, dispatch]) => dispatch)
|
||||
const config = useConfig()
|
||||
|
||||
const { getData, getSiblingData, setModified } = useForm()
|
||||
const { getData, getDataByPath, getSiblingData, setModified } = useForm()
|
||||
|
||||
const value = field?.value as T
|
||||
const initialValue = field?.initialValue as T
|
||||
@@ -116,8 +116,14 @@ const useField = <T,>(options: Options): FieldType<T> => {
|
||||
user,
|
||||
}
|
||||
|
||||
let valueToValidate = value
|
||||
|
||||
if (field?.rows && Array.isArray(field.rows)) {
|
||||
valueToValidate = getDataByPath(path)
|
||||
}
|
||||
|
||||
const validationResult =
|
||||
typeof validate === 'function' ? await validate(value, validateOptions) : true
|
||||
typeof validate === 'function' ? await validate(valueToValidate, validateOptions) : true
|
||||
|
||||
if (typeof validationResult === 'string') {
|
||||
action.errorMessage = validationResult
|
||||
@@ -132,7 +138,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
|
||||
}
|
||||
}
|
||||
|
||||
validateField()
|
||||
void validateField()
|
||||
},
|
||||
150,
|
||||
[
|
||||
@@ -142,6 +148,7 @@ const useField = <T,>(options: Options): FieldType<T> => {
|
||||
dispatchField,
|
||||
getData,
|
||||
getSiblingData,
|
||||
getDataByPath,
|
||||
id,
|
||||
operation,
|
||||
path,
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
}
|
||||
|
||||
&__fields {
|
||||
& > .tabs-field {
|
||||
& > .tabs-field,
|
||||
& > .group-field {
|
||||
margin-right: calc(var(--base) * -2);
|
||||
}
|
||||
}
|
||||
@@ -51,7 +52,7 @@
|
||||
position: sticky;
|
||||
top: var(--doc-controls-height);
|
||||
width: 33.33%;
|
||||
height: 100%;
|
||||
height: calc(100vh - var(--doc-controls-height));
|
||||
}
|
||||
|
||||
&__sidebar {
|
||||
@@ -110,7 +111,8 @@
|
||||
}
|
||||
|
||||
&__fields {
|
||||
& > .tabs-field {
|
||||
& > .tabs-field,
|
||||
& > .group-field {
|
||||
margin-right: calc(var(--gutter-h) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,9 +90,8 @@ export const DefaultGlobalEdit: React.FC<GlobalEditViewProps> = (props) => {
|
||||
<div className={`${baseClass}__sidebar-sticky-wrap`}>
|
||||
<div className={`${baseClass}__sidebar-fields`}>
|
||||
<RenderFields
|
||||
fieldSchema={fields}
|
||||
fieldTypes={fieldTypes}
|
||||
filter={(field) => field.admin.position === 'sidebar'}
|
||||
fields={sidebarFields}
|
||||
permissions={permissions.fields}
|
||||
readOnly={!hasSavePermission}
|
||||
/>
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -21,4 +21,11 @@
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__inputWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: base(1);
|
||||
margin-bottom: base(0.25);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ const Login: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const prefillForm = autoLogin && autoLogin.prefillOnly
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{user ? (
|
||||
@@ -75,22 +77,33 @@ const Login: React.FC = () => {
|
||||
action={`${serverURL}${api}/${userSlug}/login`}
|
||||
className={`${baseClass}__form`}
|
||||
disableSuccessStatus
|
||||
initialData={{
|
||||
email: autoLogin && autoLogin.prefillOnly ? autoLogin.email : undefined,
|
||||
password: autoLogin && autoLogin.prefillOnly ? autoLogin.password : undefined,
|
||||
}}
|
||||
initialData={
|
||||
prefillForm
|
||||
? {
|
||||
email: autoLogin.email,
|
||||
password: autoLogin.password,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
method="post"
|
||||
onSuccess={onSuccess}
|
||||
waitForAutocomplete
|
||||
>
|
||||
<FormLoadingOverlayToggle action="loading" name="login-form" />
|
||||
<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>
|
||||
|
||||
@@ -27,7 +27,8 @@
|
||||
}
|
||||
|
||||
&__fields {
|
||||
& > .tabs-field {
|
||||
& > .tabs-field,
|
||||
& > .group-field {
|
||||
margin-right: calc(var(--base) * -2);
|
||||
}
|
||||
}
|
||||
@@ -55,7 +56,7 @@
|
||||
position: sticky;
|
||||
top: var(--doc-controls-height);
|
||||
width: 33.33%;
|
||||
height: 100%;
|
||||
height: calc(100vh - var(--doc-controls-height));
|
||||
}
|
||||
|
||||
&__sidebar {
|
||||
@@ -106,7 +107,8 @@
|
||||
}
|
||||
|
||||
&__fields {
|
||||
& > .tabs-field {
|
||||
& > .tabs-field,
|
||||
& > .group-field {
|
||||
margin-right: calc(var(--gutter-h) * -1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,12 @@ export const DefaultCollectionEdit: React.FC<CollectionEditViewProps> = (props)
|
||||
<div className={`${baseClass}__sidebar`}>
|
||||
<div className={`${baseClass}__sidebar-sticky-wrap`}>
|
||||
<div className={`${baseClass}__sidebar-fields`}>
|
||||
<RenderFields fieldTypes={fieldTypes} fields={sidebarFields} />
|
||||
<RenderFields
|
||||
fieldTypes={fieldTypes}
|
||||
fields={sidebarFields}
|
||||
permissions={permissions.fields}
|
||||
readOnly={!hasSavePermission}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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)}`
|
||||
})
|
||||
|
||||
@@ -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": "Ширина"
|
||||
|
||||
17
packages/payload/src/versions/drafts/getQueryDraftsSort.ts
Normal file
17
packages/payload/src/versions/drafts/getQueryDraftsSort.ts
Normal 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}`
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ type Props = {
|
||||
nodeKey: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The actual content of the Block. This should be INSIDE a Form component,
|
||||
* scoped to the block. All format operations in here are thus scoped to the block's form, and
|
||||
* not the whole document.
|
||||
*/
|
||||
export const BlockContent: React.FC<Props> = (props) => {
|
||||
const { baseClass, block, field, fields, nodeKey } = props
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { type ElementFormatType } from 'lexical'
|
||||
import { Form, buildInitialState, useFormSubmitted } from 'payload/components/forms'
|
||||
import React, { useMemo } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
|
||||
import { type BlockFields } from '../nodes/BlocksNode'
|
||||
const baseClass = 'lexical-block'
|
||||
|
||||
import type { Data } from 'payload/types'
|
||||
|
||||
import { useConfig } from 'payload/components/utilities'
|
||||
import {
|
||||
buildStateFromSchema,
|
||||
useConfig,
|
||||
useDocumentInfo,
|
||||
useLocale,
|
||||
} from 'payload/components/utilities'
|
||||
import { sanitizeFields } from 'payload/config'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import type { BlocksFeatureProps } from '..'
|
||||
|
||||
@@ -43,13 +49,49 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
validRelationships,
|
||||
})
|
||||
|
||||
const initialDataRef = React.useRef<Data>(buildInitialState(fields.data || {})) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
|
||||
const initialStateRef = React.useRef<Data>(buildInitialState(fields.data || {})) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
|
||||
|
||||
const config = useConfig()
|
||||
const { t } = useTranslation('general')
|
||||
const { code: locale } = useLocale()
|
||||
const { getDocPreferences } = useDocumentInfo()
|
||||
|
||||
// initialState State
|
||||
|
||||
const [initialState, setInitialState] = React.useState<Data>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function buildInitialState() {
|
||||
const preferences = await getDocPreferences()
|
||||
|
||||
const stateFromSchema = await buildStateFromSchema({
|
||||
config,
|
||||
data: fields.data,
|
||||
fieldSchema: block.fields,
|
||||
locale,
|
||||
operation: 'update',
|
||||
preferences,
|
||||
t,
|
||||
})
|
||||
|
||||
// We have to merge the output of buildInitialState (above this useEffect) with the output of buildStateFromSchema.
|
||||
// That's because the output of buildInitialState provides important properties necessary for THIS block,
|
||||
// like blockName, blockType and id, while buildStateFromSchema provides the correct output of this block's data,
|
||||
// e.g. if this block has a sub-block (like the `rows` property)
|
||||
setInitialState({
|
||||
...initialStateRef?.current,
|
||||
...stateFromSchema,
|
||||
})
|
||||
}
|
||||
void buildInitialState()
|
||||
}, [setInitialState, config, block, locale, getDocPreferences, t]) // do not add fields here, it causes an endless loop
|
||||
|
||||
// Memoized Form JSX
|
||||
const formContent = useMemo(() => {
|
||||
return (
|
||||
block && (
|
||||
<Form initialState={initialDataRef?.current} submitted={submitted}>
|
||||
block &&
|
||||
initialState && (
|
||||
<Form fields={block.fields} initialState={initialState} submitted={submitted}>
|
||||
<BlockContent
|
||||
baseClass={baseClass}
|
||||
block={block}
|
||||
@@ -60,7 +102,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
|
||||
</Form>
|
||||
)
|
||||
)
|
||||
}, [block, field, nodeKey, submitted])
|
||||
}, [block, field, nodeKey, submitted, initialState])
|
||||
|
||||
return <div className={baseClass}>{formContent}</div>
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@ export const blockValidationHOC = (
|
||||
payloadConfig,
|
||||
validation,
|
||||
}) => {
|
||||
const blockFieldValues = node.fields.data
|
||||
|
||||
const blockFieldData = node.fields.data
|
||||
const blocks: Block[] = props.blocks
|
||||
|
||||
// Sanitize block's fields here. This is done here and not in the feature, because the payload config is available here
|
||||
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) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export const linkAfterReadPromiseHOC = (
|
||||
promises,
|
||||
req,
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
siblingDoc: node.fields || {},
|
||||
})
|
||||
}
|
||||
return promises
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -51,7 +51,7 @@ export const uploadAfterReadPromiseHOC = (
|
||||
promises,
|
||||
req,
|
||||
showHiddenFields,
|
||||
siblingDoc,
|
||||
siblingDoc: node.fields || {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SerializedHeadingNode } from '@lexical/rich-text'
|
||||
|
||||
import type { SlateNodeConverter } from '../types'
|
||||
|
||||
import { convertSlateNodesToLexical } from '..'
|
||||
|
||||
export const HeadingConverter: SlateNodeConverter = {
|
||||
converter({ converters, slateNode }) {
|
||||
return {
|
||||
children: convertSlateNodesToLexical({
|
||||
canContainParagraphs: false,
|
||||
converters,
|
||||
parentNodeType: 'heading',
|
||||
slateNodes: slateNode.children || [],
|
||||
}),
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
tag: slateNode.type as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', // Slate puts the tag (h1 / h2 / ...) inside of node.type
|
||||
type: 'heading',
|
||||
version: 1,
|
||||
} as const as SerializedHeadingNode
|
||||
},
|
||||
nodeTypes: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { SerializedLexicalNode, SerializedParagraphNode } from 'lexical'
|
||||
|
||||
import type { SlateNodeConverter } from '../types'
|
||||
|
||||
import { convertSlateNodesToLexical } from '..'
|
||||
|
||||
export const IndentConverter: SlateNodeConverter = {
|
||||
converter({ converters, slateNode }) {
|
||||
console.log('slateToLexical > IndentConverter > converter', JSON.stringify(slateNode, null, 2))
|
||||
const convertChildren = (node: any, indentLevel: number = 0): SerializedLexicalNode => {
|
||||
if (
|
||||
(node?.type && (!node.children || node.type !== 'indent')) ||
|
||||
(!node?.type && node?.text)
|
||||
) {
|
||||
console.log(
|
||||
'slateToLexical > IndentConverter > convertChildren > node',
|
||||
JSON.stringify(node, null, 2),
|
||||
)
|
||||
console.log(
|
||||
'slateToLexical > IndentConverter > convertChildren > nodeOutput',
|
||||
JSON.stringify(
|
||||
convertSlateNodesToLexical({
|
||||
canContainParagraphs: false,
|
||||
converters,
|
||||
parentNodeType: 'indent',
|
||||
slateNodes: [node],
|
||||
}),
|
||||
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
...convertSlateNodesToLexical({
|
||||
canContainParagraphs: false,
|
||||
converters,
|
||||
parentNodeType: 'indent',
|
||||
slateNodes: [node],
|
||||
})[0],
|
||||
indent: indentLevel,
|
||||
} as const as SerializedLexicalNode
|
||||
}
|
||||
|
||||
const children = node.children.map((child: any) => convertChildren(child, indentLevel + 1))
|
||||
console.log('slateToLexical > IndentConverter > children', JSON.stringify(children, null, 2))
|
||||
return {
|
||||
children: children,
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: indentLevel,
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
} as const as SerializedParagraphNode
|
||||
}
|
||||
|
||||
console.log(
|
||||
'slateToLexical > IndentConverter > output',
|
||||
JSON.stringify(convertChildren(slateNode), null, 2),
|
||||
)
|
||||
|
||||
return convertChildren(slateNode)
|
||||
},
|
||||
nodeTypes: ['indent'],
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SerializedLinkNode } from '../../../../Link/nodes/LinkNode'
|
||||
import type { SlateNodeConverter } from '../types'
|
||||
|
||||
import { convertSlateNodesToLexical } from '..'
|
||||
|
||||
export const LinkConverter: SlateNodeConverter = {
|
||||
converter({ converters, slateNode }) {
|
||||
return {
|
||||
children: convertSlateNodesToLexical({
|
||||
canContainParagraphs: false,
|
||||
converters,
|
||||
parentNodeType: 'link',
|
||||
slateNodes: slateNode.children || [],
|
||||
}),
|
||||
direction: 'ltr',
|
||||
fields: {
|
||||
doc: slateNode.doc || undefined,
|
||||
linkType: slateNode.linkType || 'custom',
|
||||
newTab: slateNode.newTab || false,
|
||||
url: slateNode.url || undefined,
|
||||
},
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'link',
|
||||
version: 1,
|
||||
} as const as SerializedLinkNode
|
||||
},
|
||||
nodeTypes: ['link'],
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SerializedListItemNode } from '@lexical/list'
|
||||
|
||||
import type { SlateNodeConverter } from '../types'
|
||||
|
||||
import { convertSlateNodesToLexical } from '..'
|
||||
|
||||
export const ListItemConverter: SlateNodeConverter = {
|
||||
converter({ childIndex, converters, slateNode }) {
|
||||
return {
|
||||
checked: undefined,
|
||||
children: convertSlateNodesToLexical({
|
||||
canContainParagraphs: false,
|
||||
converters,
|
||||
parentNodeType: 'listitem',
|
||||
slateNodes: slateNode.children || [],
|
||||
}),
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'listitem',
|
||||
value: childIndex + 1,
|
||||
version: 1,
|
||||
} as const as SerializedListItemNode
|
||||
},
|
||||
nodeTypes: ['li'],
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SerializedListNode } from '@lexical/list'
|
||||
|
||||
import type { SlateNodeConverter } from '../types'
|
||||
|
||||
import { convertSlateNodesToLexical } from '..'
|
||||
|
||||
export const OrderedListConverter: SlateNodeConverter = {
|
||||
converter({ converters, slateNode }) {
|
||||
return {
|
||||
children: convertSlateNodesToLexical({
|
||||
canContainParagraphs: false,
|
||||
converters,
|
||||
parentNodeType: 'list',
|
||||
slateNodes: slateNode.children || [],
|
||||
}),
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
listType: 'number',
|
||||
start: 1,
|
||||
tag: 'ol',
|
||||
type: 'list',
|
||||
version: 1,
|
||||
} as const as SerializedListNode
|
||||
},
|
||||
nodeTypes: ['ol'],
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { SerializedRelationshipNode } from '../../../../../..'
|
||||
import type { SlateNodeConverter } from '../types'
|
||||
|
||||
export const RelationshipConverter: SlateNodeConverter = {
|
||||
converter({ slateNode }) {
|
||||
return {
|
||||
format: '',
|
||||
relationTo: slateNode.relationTo,
|
||||
type: 'relationship',
|
||||
value: {
|
||||
id: slateNode?.value?.id || '',
|
||||
},
|
||||
version: 1,
|
||||
} as const as SerializedRelationshipNode
|
||||
},
|
||||
nodeTypes: ['relationship'],
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SerializedUnknownConvertedNode } from '../../nodes/unknownConvertedNode'
|
||||
import type { SlateNodeConverter } from '../types'
|
||||
|
||||
import { convertSlateNodesToLexical } from '..'
|
||||
|
||||
export const UnknownConverter: SlateNodeConverter = {
|
||||
converter({ converters, slateNode }) {
|
||||
return {
|
||||
children: convertSlateNodesToLexical({
|
||||
canContainParagraphs: false,
|
||||
converters,
|
||||
parentNodeType: 'unknownConverted',
|
||||
slateNodes: slateNode.children || [],
|
||||
}),
|
||||
data: {
|
||||
nodeData: slateNode,
|
||||
nodeType: slateNode.type,
|
||||
},
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'unknownConverted',
|
||||
version: 1,
|
||||
} as const as SerializedUnknownConvertedNode
|
||||
},
|
||||
nodeTypes: ['unknown'],
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SerializedListNode } from '@lexical/list'
|
||||
|
||||
import type { SlateNodeConverter } from '../types'
|
||||
|
||||
import { convertSlateNodesToLexical } from '..'
|
||||
|
||||
export const UnorderedListConverter: SlateNodeConverter = {
|
||||
converter({ converters, slateNode }) {
|
||||
return {
|
||||
children: convertSlateNodesToLexical({
|
||||
canContainParagraphs: false,
|
||||
converters,
|
||||
parentNodeType: 'list',
|
||||
slateNodes: slateNode.children || [],
|
||||
}),
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
listType: 'bullet',
|
||||
start: 1,
|
||||
tag: 'ul',
|
||||
type: 'list',
|
||||
version: 1,
|
||||
} as const as SerializedListNode
|
||||
},
|
||||
nodeTypes: ['ul'],
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { SerializedUploadNode } from '../../../../../..'
|
||||
import type { SlateNodeConverter } from '../types'
|
||||
|
||||
export const UploadConverter: SlateNodeConverter = {
|
||||
converter({ slateNode }) {
|
||||
return {
|
||||
fields: {
|
||||
...slateNode.fields,
|
||||
},
|
||||
format: '',
|
||||
relationTo: slateNode.relationTo,
|
||||
type: 'upload',
|
||||
value: {
|
||||
id: slateNode.value?.id || '',
|
||||
},
|
||||
version: 1,
|
||||
} as const as SerializedUploadNode
|
||||
},
|
||||
nodeTypes: ['upload'],
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SlateNodeConverter } from './types'
|
||||
|
||||
import { HeadingConverter } from './converters/heading'
|
||||
import { IndentConverter } from './converters/indent'
|
||||
import { LinkConverter } from './converters/link'
|
||||
import { ListItemConverter } from './converters/listItem'
|
||||
import { OrderedListConverter } from './converters/orderedList'
|
||||
import { RelationshipConverter } from './converters/relationship'
|
||||
import { UnknownConverter } from './converters/unknown'
|
||||
import { UnorderedListConverter } from './converters/unorderedList'
|
||||
import { UploadConverter } from './converters/upload'
|
||||
|
||||
export const defaultConverters: SlateNodeConverter[] = [
|
||||
UnknownConverter,
|
||||
UploadConverter,
|
||||
UnorderedListConverter,
|
||||
OrderedListConverter,
|
||||
RelationshipConverter,
|
||||
ListItemConverter,
|
||||
LinkConverter,
|
||||
HeadingConverter,
|
||||
IndentConverter,
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
SerializedEditorState,
|
||||
SerializedLexicalNode,
|
||||
SerializedParagraphNode,
|
||||
SerializedTextNode,
|
||||
} from 'lexical'
|
||||
|
||||
import type { SlateNode, SlateNodeConverter } from './types'
|
||||
|
||||
import { NodeFormat } from '../../../../lexical/utils/nodeFormat'
|
||||
|
||||
export function convertSlateToLexical({
|
||||
converters,
|
||||
slateData,
|
||||
}: {
|
||||
converters: SlateNodeConverter[]
|
||||
slateData: SlateNode[]
|
||||
}): SerializedEditorState {
|
||||
return {
|
||||
root: {
|
||||
children: convertSlateNodesToLexical({
|
||||
canContainParagraphs: true,
|
||||
converters,
|
||||
parentNodeType: 'root',
|
||||
slateNodes: slateData,
|
||||
}),
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'root',
|
||||
version: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function convertSlateNodesToLexical({
|
||||
canContainParagraphs,
|
||||
converters,
|
||||
parentNodeType,
|
||||
slateNodes,
|
||||
}: {
|
||||
canContainParagraphs: boolean
|
||||
converters: SlateNodeConverter[]
|
||||
/**
|
||||
* Type of the parent lexical node (not the type of the original, parent slate type)
|
||||
*/
|
||||
parentNodeType: string
|
||||
slateNodes: SlateNode[]
|
||||
}): SerializedLexicalNode[] {
|
||||
const unknownConverter = converters.find((converter) => converter.nodeTypes.includes('unknown'))
|
||||
return (
|
||||
slateNodes.map((slateNode, i) => {
|
||||
if (!('type' in slateNode)) {
|
||||
if (canContainParagraphs) {
|
||||
// This is a paragraph node. They do not have a type property in Slate
|
||||
return convertParagraphNode(converters, slateNode)
|
||||
} else {
|
||||
// This is a simple text node. canContainParagraphs may be false if this is nested inside of a paragraph already, since paragraphs cannot contain paragraphs
|
||||
return convertTextNode(slateNode)
|
||||
}
|
||||
}
|
||||
if (slateNode.type === 'p') {
|
||||
return convertParagraphNode(converters, slateNode)
|
||||
}
|
||||
|
||||
const converter = converters.find((converter) => converter.nodeTypes.includes(slateNode.type))
|
||||
|
||||
if (converter) {
|
||||
return converter.converter({ childIndex: i, converters, parentNodeType, slateNode })
|
||||
}
|
||||
|
||||
console.warn('slateToLexical > No converter found for node type: ' + slateNode.type)
|
||||
return unknownConverter?.converter({
|
||||
childIndex: i,
|
||||
converters,
|
||||
parentNodeType,
|
||||
slateNode,
|
||||
})
|
||||
}) || []
|
||||
)
|
||||
}
|
||||
|
||||
export function convertParagraphNode(
|
||||
converters: SlateNodeConverter[],
|
||||
node: SlateNode,
|
||||
): SerializedParagraphNode {
|
||||
return {
|
||||
children: convertSlateNodesToLexical({
|
||||
canContainParagraphs: false,
|
||||
converters,
|
||||
parentNodeType: 'paragraph',
|
||||
slateNodes: node.children || [],
|
||||
}),
|
||||
direction: 'ltr',
|
||||
format: '',
|
||||
indent: 0,
|
||||
type: 'paragraph',
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
export function convertTextNode(node: SlateNode): SerializedTextNode {
|
||||
return {
|
||||
detail: 0,
|
||||
format: convertNodeToFormat(node),
|
||||
mode: 'normal',
|
||||
style: '',
|
||||
text: node.text,
|
||||
type: 'text',
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export function convertNodeToFormat(node: SlateNode): number {
|
||||
let format = 0
|
||||
if (node.bold) {
|
||||
format = format | NodeFormat.IS_BOLD
|
||||
}
|
||||
if (node.italic) {
|
||||
format = format | NodeFormat.IS_ITALIC
|
||||
}
|
||||
if (node.strikethrough) {
|
||||
format = format | NodeFormat.IS_STRIKETHROUGH
|
||||
}
|
||||
if (node.underline) {
|
||||
format = format | NodeFormat.IS_UNDERLINE
|
||||
}
|
||||
if (node.subscript) {
|
||||
format = format | NodeFormat.IS_SUBSCRIPT
|
||||
}
|
||||
if (node.superscript) {
|
||||
format = format | NodeFormat.IS_SUPERSCRIPT
|
||||
}
|
||||
if (node.code) {
|
||||
format = format | NodeFormat.IS_CODE
|
||||
}
|
||||
return format
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { SerializedLexicalNode } from 'lexical'
|
||||
|
||||
export type SlateNodeConverter<T extends SerializedLexicalNode = SerializedLexicalNode> = {
|
||||
converter: ({
|
||||
childIndex,
|
||||
converters,
|
||||
parentNodeType,
|
||||
slateNode,
|
||||
}: {
|
||||
childIndex: number
|
||||
converters: SlateNodeConverter[]
|
||||
parentNodeType: string
|
||||
slateNode: SlateNode
|
||||
}) => T
|
||||
nodeTypes: string[]
|
||||
}
|
||||
|
||||
export type SlateNode = {
|
||||
[key: string]: any
|
||||
children?: SlateNode[]
|
||||
type?: string // doesn't always have type, e.g. for paragraphs
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FeatureProvider } from '../../types'
|
||||
import type { SlateNodeConverter } from './converter/types'
|
||||
|
||||
import { convertSlateToLexical } from './converter'
|
||||
import { defaultConverters } from './converter/defaultConverters'
|
||||
import { UnknownConvertedNode } from './nodes/unknownConvertedNode'
|
||||
|
||||
type Props = {
|
||||
converters?:
|
||||
| (({ defaultConverters }: { defaultConverters: SlateNodeConverter[] }) => SlateNodeConverter[])
|
||||
| SlateNodeConverter[]
|
||||
}
|
||||
|
||||
export const SlateToLexicalFeature = (props?: Props): FeatureProvider => {
|
||||
if (!props) {
|
||||
props = {}
|
||||
}
|
||||
|
||||
props.converters =
|
||||
props?.converters && typeof props?.converters === 'function'
|
||||
? props.converters({ defaultConverters: defaultConverters })
|
||||
: (props?.converters as SlateNodeConverter[]) || defaultConverters
|
||||
|
||||
return {
|
||||
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
|
||||
return {
|
||||
hooks: {
|
||||
load({ incomingEditorState }) {
|
||||
if (
|
||||
!incomingEditorState ||
|
||||
!Array.isArray(incomingEditorState) ||
|
||||
'root' in incomingEditorState
|
||||
) {
|
||||
// incomingEditorState null or not from Slate
|
||||
return incomingEditorState
|
||||
}
|
||||
// Slate => convert to lexical
|
||||
|
||||
return convertSlateToLexical({
|
||||
converters: props.converters as SlateNodeConverter[],
|
||||
slateData: incomingEditorState,
|
||||
})
|
||||
},
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
node: UnknownConvertedNode,
|
||||
type: UnknownConvertedNode.getType(),
|
||||
},
|
||||
],
|
||||
props,
|
||||
}
|
||||
},
|
||||
key: 'slateToLexical',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@import 'payload/scss';
|
||||
|
||||
span.unknownConverted {
|
||||
text-transform: uppercase;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
letter-spacing: 2px;
|
||||
font-size: base(0.5);
|
||||
margin: 0 0 base(1);
|
||||
background: red;
|
||||
color: white;
|
||||
display: inline-block;
|
||||
|
||||
div {
|
||||
background: red;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { SerializedLexicalNode, Spread } from 'lexical'
|
||||
|
||||
import { addClassNamesToElement } from '@lexical/utils'
|
||||
import { DecoratorNode, type EditorConfig, type LexicalNode, type NodeKey } from 'lexical'
|
||||
import React from 'react'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
export type UnknownConvertedNodeData = {
|
||||
nodeData: unknown
|
||||
nodeType: string
|
||||
}
|
||||
|
||||
export type SerializedUnknownConvertedNode = Spread<
|
||||
{
|
||||
data: UnknownConvertedNodeData
|
||||
},
|
||||
SerializedLexicalNode
|
||||
>
|
||||
|
||||
/** @noInheritDoc */
|
||||
export class UnknownConvertedNode extends DecoratorNode<JSX.Element> {
|
||||
__data: UnknownConvertedNodeData
|
||||
|
||||
constructor({ data, key }: { data: UnknownConvertedNodeData; key?: NodeKey }) {
|
||||
super(key)
|
||||
this.__data = data
|
||||
}
|
||||
|
||||
static clone(node: UnknownConvertedNode): UnknownConvertedNode {
|
||||
return new UnknownConvertedNode({
|
||||
data: node.__data,
|
||||
key: node.__key,
|
||||
})
|
||||
}
|
||||
|
||||
static getType(): string {
|
||||
return 'unknownConverted'
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedUnknownConvertedNode): UnknownConvertedNode {
|
||||
const node = $createUnknownConvertedNode({ data: serializedNode.data })
|
||||
return node
|
||||
}
|
||||
|
||||
canInsertTextAfter(): true {
|
||||
return true
|
||||
}
|
||||
|
||||
canInsertTextBefore(): true {
|
||||
return true
|
||||
}
|
||||
|
||||
createDOM(config: EditorConfig): HTMLElement {
|
||||
const element = document.createElement('span')
|
||||
addClassNamesToElement(element, 'unknownConverted')
|
||||
return element
|
||||
}
|
||||
|
||||
decorate(): JSX.Element | null {
|
||||
return <div>Unknown converted Slate node: {this.__data?.nodeType}</div>
|
||||
}
|
||||
|
||||
exportJSON(): SerializedUnknownConvertedNode {
|
||||
return {
|
||||
data: this.__data,
|
||||
type: this.getType(),
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Mutation
|
||||
|
||||
isInline(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
updateDOM(prevNode: UnknownConvertedNode, dom: HTMLElement): boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function $createUnknownConvertedNode({
|
||||
data,
|
||||
}: {
|
||||
data: UnknownConvertedNodeData
|
||||
}): UnknownConvertedNode {
|
||||
return new UnknownConvertedNode({
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function $isUnknownConvertedNode(
|
||||
node: LexicalNode | null | undefined,
|
||||
): node is UnknownConvertedNode {
|
||||
return node instanceof UnknownConvertedNode
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
display: flex;
|
||||
isolation: isolate;
|
||||
|
||||
.errorBoundary {
|
||||
pre {
|
||||
text-wrap: unset;
|
||||
}
|
||||
}
|
||||
|
||||
&__wrap {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user