Compare commits

..

31 Commits

Author SHA1 Message Date
Elliot DeNolf
e5427ad3b8 chore(release): v3.0.0-beta.46 [skip ci] 2024-06-12 16:16:41 -04:00
Jessica Chowdhury
763a34f19b fix: corrects block duplicate action and add tests (#6589) 2024-06-12 14:44:17 -04:00
Elliot DeNolf
be0462db56 feat: diff generated types before write (#6749)
Diff types on disk before write
2024-06-12 14:16:03 -04:00
Elliot DeNolf
6e55a2e52d fix: unawaited emails (#6265)
Await email sending, serverless may end before send

Fixes #6457
2024-06-12 14:02:05 -04:00
Alessio Gravili
4e127054ca feat(richtext-lexical)!: sub-field hooks and localization support (#6591)
## BREAKING
- Our internal field hook methods now have new required `schemaPath` and
path `props`. This affects the following functions, if you are using
those: `afterChangeTraverseFields`, `afterReadTraverseFields`,
`beforeChangeTraverseFields`, `beforeValidateTraverseFields`,
`afterReadPromise`
- The afterChange field hook's `value` is now the value AFTER the
previous hooks were run. Previously, this was the original value, which
I believe is a bug
- Only relevant if you have built your own richText adapter: the
richText adapter `populationPromises` property has been renamed to
`graphQLPopulationPromises` and is now only run for graphQL. Previously,
it was run for graphQL AND the rest API. To migrate, use
`hooks.afterRead` to run population for the rest API
- Only relevant if you have built your own lexical features: The
`populationPromises` server feature property has been renamed to
`graphQLPopulationPromises` and is now only run for graphQL. Previously,
it was run for graphQL AND the rest API. To migrate, use
`hooks.afterRead` to run population for the rest API
- Serialized lexical link and upload nodes now have a new `id` property.
While not breaking, localization / hooks will not work for their fields
until you have migrated to that. Re-saving the old document on the new
version will automatically add the `id` property for you. You will also
get a bunch of console logs for every lexical node which is not migrated
2024-06-12 13:33:08 -04:00
Elliot DeNolf
27510bb963 chore(templates): fix vercel one click links [skip ci] 2024-06-11 16:30:11 -04:00
Anders Semb Hermansen
de45e6094b fix(ui): hideGutter was ignored in group field (#6613) 2024-06-11 16:26:00 -04:00
Patryk Kowalczyk
74159de1ec fix: add missing export for useLeaf hook (#6693) 2024-06-11 16:12:25 -04:00
Jarrod Flesch
ba92d864bb fix: list sort preferences (#6731)
Fixes https://github.com/payloadcms/payload/issues/6617

Sets preferences when list sort is set. Uses defaultSort when defined in
config and preferences are not set.
2024-06-11 16:02:28 -04:00
Elliot DeNolf
0fb14cfebe chore(release): v3.0.0-beta.45 [skip ci] 2024-06-11 15:09:41 -04:00
Paul
2ada6fc58d fix: toasts padding and button placement by 1px (#6730)
## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)
2024-06-11 18:42:17 +00:00
Alessio Gravili
cb3355b30f feat!: move from react-toastify to sonner (#6682)
**BREAKING:** We now export toast from `sonner` instead of
`react-toastify`. If you send out toasts from your own projects, make
sure to use our `toast` export, or install `sonner`. React-toastify
toasts will no longer work anymore. The Toast APIs are mostly similar,
but there are some differences if you provide options to your toast

CSS styles have been changed from Toastify

```css
/* before */
.Toastify


/* current */
.payload-toast-container
.payload-toast-item
.payload-toast-close-button

/* individual toast items will also have these classes depending on the state */
.toast-info
.toast-warning
.toast-success
.toast-error
```


https://github.com/payloadcms/payload/assets/70709113/da3e732e-aafc-4008-9469-b10f4eb06b35

---------

Co-authored-by: Paul Popus <paul@nouance.io>
2024-06-11 14:12:59 -04:00
Patrik
10c6ffafc3 fix: only use metadata.pages for height if animated (#6728)
## Description

### Issue: 

Non-animated webp / gif files were using `metadata.pages` to calculate
it's resized heights for `imageSizes` or `cropping`.

### Fix: 

It should only use this to calculate it's height if the file's
`metadata` contains `metadata.pages`. Non-animated webps and gifs would
not have this.

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] Existing test suite passes locally with my changes
2024-06-11 13:45:49 -04:00
Patrik
6512d5ce69 fix: create sharp file for fileHasAdjustments files or fileIsAnimated files (#6708)
## Description

Fixes #6694 

Previously we were only creating sharp files for files that have file
adjustments but instead a sharp file should be created for animated
images even if there are no file adjustments - i.e

`const fileHasAdjustments = fileSupportsResize && Boolean(resizeOptions
|| formatOptions || imageSizes || trimOptions || file.tempFilePath)`

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] Existing test suite passes locally with my changes
2024-06-11 10:55:51 -04:00
Jarrod Flesch
57fcc9148e fix: corrects field-paths that were incorrectly being set (#6724)
Fixes https://github.com/payloadcms/payload/issues/6650

Similar to [6712](https://github.com/payloadcms/payload/pull/6712). Field paths were
not accounting for the 4 scenarios:
- both parentPath & fieldName
- only parentPath
- only fieldName
- neither parentPath or fieldName (top level rows, etc)
2024-06-11 10:17:40 -04:00
Elliot DeNolf
36f4f23463 chore(release): v3.0.0-beta.44 [skip ci] 2024-06-11 09:46:31 -04:00
Alessio Gravili
7b7dc71845 fix: get auto type-gen to work on turbo, by running type gen in a child process outside turbo/webpack (#6714)
Before on turbo: https://github.com/vercel/next.js/issues/66723
2024-06-10 22:03:12 +00:00
Jarrod Flesch
ba513d5a97 fix: corrects tab paths when nested within other row like fields (#6712)
Fixes https://github.com/payloadcms/payload/issues/6637

There was an issue where tab paths were being generated based on 2
scenarios when there are 3 possible scenarios:
- A path is provided and the tab is named
- A path is **not** provided but the tab is named
- Neither a path or a tab name are provided
2024-06-10 16:06:09 -04:00
Jarrod Flesch
a26d03190e fix: re-exports graphql json types for external use (#6711)
Fixes https://github.com/payloadcms/payload/issues/6683

Exports import `GraphQLJSON` and `GraphQLJSONObject` from
`@payloadcms/graphql/types`

```ts
import { GraphQLJSON, GraphQLJSONObject } from '@payloadcms/graphql/types'
```
2024-06-10 14:53:31 -04:00
Patrik
9f525621c8 fix(ui): removes array & blocks & group fields from sort (#6576)
## Description

V2 PR [here](https://github.com/payloadcms/payload/pull/6574)

- [x] I have read and understand the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## Checklist:

- [x] Existing test suite passes locally with my changes
2024-06-10 14:09:50 -04:00
Elliot DeNolf
7309d474ee feat!: type auto-generation (#6657)
Types are now auto-generated by default.

You can opt-out of this behavior by setting:
```ts
buildConfig({
  // Rest of config
  typescript: {
    autoGenerate: false
  },
})
```
2024-06-10 13:42:44 -04:00
Jarrod Flesch
45e86832c2 fix: global draft validations (#6709)
- Extends draft validation from https://github.com/payloadcms/payload/pull/6677 to work with globals as
well

- Fixes bug from https://github.com/payloadcms/payload/pull/6677 where
autosave was not saving properly after first autosave
2024-06-10 12:31:22 -04:00
Alessio Gravili
1bd91b23ca chore: improved clean commands which work on windows and output pretty summary (#6685) 2024-06-09 05:21:11 +00:00
Alessio Gravili
ac34380eb8 fix(ui): set checkbox htmlFor by default, fixing some checkbox labels not toggling the checkbox (#6684) 2024-06-08 19:34:26 +00:00
Jacob Fletcher
17707852e0 chore: migrates @faceless-ui imports to esm (#6681) 2024-06-07 22:59:39 -04:00
Elliot DeNolf
8b95218577 chore(release): v3.0.0-beta.43 [skip ci] 2024-06-07 17:45:28 -04:00
Jarrod Flesch
a79d23c631 chore: adjusts test config for draft validation (#6678) 2024-06-07 16:01:03 -04:00
Jarrod Flesch
52c81ad525 feat: adds draft validation option (#6677)
## Description

Allows draft validation to be enabled at the config level.

You can enable this by:
```ts
// ...collectionConfig
versions: {
  drafts: {
    validate: true // defaults to false
  }
}
```
2024-06-07 15:22:03 -04:00
Paul
8ec836737e chore: add turbo resolveAlias mock alias to hide webpack warnings (#6676) 2024-06-07 17:23:35 +00:00
Paul
e4a90294ea feat(plugin-redirects)!: update fields overrides to use a function (#6675)
## Description

Updates the `fields` override in plugin redirects to allow for
overriding

```ts
// before
overrides: {
  fields: [
    {
      type: 'text',
      name: 'customField',
    },
  ],
},

// current
overrides: {
  fields: ({ defaultFields }) => {
    return [
      ...defaultFields,
      {
        type: 'text',
        name: 'customField',
      },
    ]
  },
},
```


## Type of change

- [x] New feature (non-breaking change which adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
2024-06-07 14:41:09 +00:00
Jacob Fletcher
7c8d562f03 fix(next): live preview device position when using zoom (#6665) 2024-06-07 10:17:49 -04:00
328 changed files with 4691 additions and 1424 deletions

7
.vscode/launch.json vendored
View File

@@ -111,6 +111,13 @@
"request": "launch",
"type": "node-terminal"
},
{
"command": "node --no-deprecation test/dev.js field-error-states",
"cwd": "${workspaceFolder}",
"name": "Run Dev Field Error States",
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm run test:int live-preview",
"cwd": "${workspaceFolder}",

View File

@@ -22,6 +22,7 @@ Collections and Globals both support the same options for configuring drafts. Yo
| Draft Option | Description |
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `autosave` | Enable `autosave` to automatically save progress while documents are edited. To enable, set to `true` or pass an object with [options](/docs/versions/autosave). |
| `validate` | Set `validate` to `true` to validate draft documents when saved. Default is `false`. |
## Database changes

View File

@@ -33,6 +33,7 @@ export default withBundleAnalyzer(
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
}),

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"private": true,
"type": "module",
"scripts": {
@@ -9,6 +9,7 @@
"build:app": "next build",
"build:app:analyze": "cross-env ANALYZE=true next build",
"build:core": "turbo build --filter \"!@payloadcms/plugin-*\"",
"build:core:force": "pnpm clean:build && turbo build --filter \"!@payloadcms/plugin-*\" --no-cache --force",
"build:create-payload-app": "turbo build --filter create-payload-app",
"build:db-mongodb": "turbo build --filter db-mongodb",
"build:db-postgres": "turbo build --filter db-postgres",
@@ -42,21 +43,21 @@
"build:translations": "turbo build --filter translations",
"build:ui": "turbo build --filter ui",
"clean": "turbo clean",
"clean:all": "find . \\( -type d \\( -name node_modules -o -name dist -o -name .cache -o -name .next -o -name .turbo \\) -o -type f -name tsconfig.tsbuildinfo \\) -exec rm -rf {} +",
"clean:build": "find . \\( -type d \\( -name dist -o -name .cache -o -name .next -o -name .turbo \\) -o -type f -name tsconfig.tsbuildinfo \\) -not -path '*/node_modules/*' -exec rm -rf {} +",
"clean:cache": "rimraf node_modules/.cache && rimraf packages/payload/node_modules/.cache && rimraf .next",
"clean:all": "node ./scripts/delete-recursively.js '@node_modules' 'media' '**/dist' '**/.cache' '**/.next' '**/.turbo' '**/tsconfig.tsbuildinfo' '**/payload*.tgz'",
"clean:build": "node ./scripts/delete-recursively.js 'media' '**/dist' '**/.cache' '**/.next' '**/.turbo' '**/tsconfig.tsbuildinfo' '**/payload*.tgz'",
"clean:cache": "node ./scripts/delete-recursively.js node_modules/.cache! packages/payload/node_modules/.cache! .next",
"dev": "cross-env NODE_OPTIONS=--no-deprecation node ./test/dev.js",
"dev:generate-graphql-schema": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/generateGraphQLSchema.ts",
"dev:generate-types": "cross-env NODE_OPTIONS=--no-deprecation tsx ./test/generateTypes.ts",
"dev:postgres": "cross-env NODE_OPTIONS=--no-deprecation PAYLOAD_DATABASE=postgres node ./test/dev.js",
"devsafe": "rimraf .next && pnpm dev",
"devsafe": "node ./scripts/delete-recursively.js '**/.next' && pnpm dev",
"docker:restart": "pnpm docker:stop --remove-orphans && pnpm docker:start",
"docker:start": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml up -d",
"docker:stop": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml down",
"fix": "eslint \"packages/**/*.ts\" --fix",
"lint": "eslint \"packages/**/*.ts\"",
"lint-staged": "lint-staged",
"obliterate-playwright-cache": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
"obliterate-playwright-cache-macos": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
"prepare": "husky install",
"reinstall": "pnpm clean:all && pnpm install",
"release:alpha": "tsx ./scripts/release.ts --bump prerelease --tag alpha",
@@ -123,6 +124,7 @@
"husky": "^8.0.3",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"json-schema-to-typescript": "11.0.3",
"lint-staged": "^14.0.1",
"minimist": "1.2.8",
"mongodb-memory-server": "^9.0",

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "Payload Nodemailer Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-resend",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -0,0 +1 @@
export { GraphQLJSON, GraphQLJSONObject } from '../packages/graphql-type-json/index.js'

View File

@@ -1 +1,2 @@
export { generateSchema } from '../bin/generateSchema.js'
export { buildObjectType } from '../schema/buildObjectType.js'

View File

@@ -81,7 +81,7 @@ type Args = {
parentName: string
}
function buildObjectType({
export function buildObjectType({
name,
baseFields = {},
config,
@@ -492,13 +492,13 @@ function buildObjectType({
// is run here again, with the provided depth.
// In the graphql find.ts resolver, the depth is then hard-coded to 0.
// Effectively, this means that the populationPromise for GraphQL is only run here, and not in the find.ts resolver / normal population promise.
if (editor?.populationPromises) {
if (editor?.graphQLPopulationPromises) {
const fieldPromises = []
const populationPromises = []
const populateDepth =
field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth
editor?.populationPromises({
editor?.graphQLPopulationPromises({
context,
depth: populateDepth,
draft: args.draft,
@@ -698,5 +698,3 @@ function buildObjectType({
return newlyCreatedBlockType
}
export default buildObjectType

View File

@@ -37,7 +37,7 @@ import restoreVersionResolver from '../resolvers/collections/restoreVersion.js'
import { updateResolver } from '../resolvers/collections/update.js'
import formatName from '../utilities/formatName.js'
import { buildMutationInputType, getCollectionIDType } from './buildMutationInputType.js'
import buildObjectType from './buildObjectType.js'
import { buildObjectType } from './buildObjectType.js'
import buildPaginatedListType from './buildPaginatedListType.js'
import { buildPolicyType } from './buildPoliciesType.js'
import buildWhereInputType from './buildWhereInputType.js'

View File

@@ -17,7 +17,7 @@ import restoreVersionResolver from '../resolvers/globals/restoreVersion.js'
import updateResolver from '../resolvers/globals/update.js'
import formatName from '../utilities/formatName.js'
import { buildMutationInputType } from './buildMutationInputType.js'
import buildObjectType from './buildObjectType.js'
import { buildObjectType } from './buildObjectType.js'
import buildPaginatedListType from './buildPaginatedListType.js'
import { buildPolicyType } from './buildPoliciesType.js'
import buildWhereInputType from './buildWhereInputType.js'

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "The official live preview React SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -54,8 +54,8 @@
"path-to-regexp": "^6.2.1",
"qs": "6.11.2",
"react-diff-viewer-continued": "3.2.6",
"react-toastify": "10.0.5",
"sass": "1.77.4",
"sonner": "^1.5.0",
"ws": "^8.16.0"
},
"devDependencies": {

View File

@@ -11,7 +11,6 @@ import { headers as getHeaders, cookies as nextCookies } from 'next/headers.js'
import { parseCookies } from 'payload/auth'
import { createClientConfig } from 'payload/config'
import React from 'react'
import 'react-toastify/dist/ReactToastify.css'
import { getPayloadHMR } from '../../utilities/getPayloadHMR.js'
import { getRequestLanguage } from '../../utilities/getRequestLanguage.js'

View File

@@ -1,5 +1,5 @@
@import './styles.scss';
@import './toastify.scss';
@import './toasts.scss';
@import './colors.scss';
:root {

View File

@@ -1,58 +0,0 @@
@import 'vars';
.Toastify {
.Toastify__toast-container {
left: base(5);
transform: none;
right: base(5);
width: auto;
}
.Toastify__toast {
padding: base(0.5);
border-radius: $style-radius-m;
font-weight: 600;
}
.Toastify__close-button {
align-self: center;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
.Toastify__toast--success {
color: var(--color-success-900);
background: var(--color-success-500);
.Toastify__progress-bar {
background-color: var(--color-success-900);
}
}
.Toastify__close-button--success {
color: var(--color-success-900);
}
.Toastify__toast--error {
background: var(--theme-error-500);
color: #fff;
.Toastify__progress-bar {
background-color: #fff;
}
}
.Toastify__close-button--light {
color: inherit;
}
@include mid-break {
.Toastify__toast-container {
left: $baseline;
right: $baseline;
}
}
}

View File

@@ -0,0 +1,111 @@
.payload-toast-container {
.payload-toast-close-button {
left: unset;
right: 0.5rem;
top: 1.55rem;
color: var(--theme-elevation-400);
background: unset;
border: none;
display: flex;
width: 1.25rem;
height: 1.25rem;
justify-content: center;
align-items: center;
&:hover {
background: none;
}
svg {
width: 2rem;
height: 2rem;
}
[dir='RTL'] & {
right: unset;
left: 0.5rem;
}
}
.payload-toast-item {
padding: 1rem 2.5rem 1rem 1rem;
color: var(--theme-text);
font-style: normal;
font-weight: 600;
display: flex;
gap: 1rem;
align-items: center;
width: 100%;
border-radius: 0.15rem;
border: 1px solid var(--theme-border-color);
background: var(--theme-input-bg);
box-shadow:
0px 10px 4px -8px rgba(0, 2, 4, 0.02),
0px 2px 3px 0px rgba(0, 2, 4, 0.05);
.toast-content {
transition: opacity 100ms cubic-bezier(0.55, 0.055, 0.675, 0.19);
}
&[data-front='false'] {
.toast-content {
opacity: 0;
}
}
&[data-expanded='true'] {
.toast-content {
opacity: 1;
}
}
.toast-icon {
svg {
width: 2.4rem;
height: 2.4rem;
}
}
&.toast-warning {
border-color: var(--theme-warning-200);
background-color: var(--theme-warning-100);
}
&.toast-error {
border-color: var(--theme-error-300);
background-color: var(--theme-error-150);
}
&.toast-success {
border-color: var(--theme-success-200);
background-color: var(--theme-success-100);
}
&.toast-info {
border-color: var(--theme-elevation-250);
background-color: var(--theme-elevation-100);
}
[data-theme='light'] & {
&.toast-warning {
border-color: var(--theme-warning-550);
background-color: var(--theme-warning-100);
}
&.toast-error {
border-color: var(--theme-error-200);
background-color: var(--theme-error-50);
}
&.toast-success {
border-color: var(--theme-success-550);
background-color: var(--theme-success-50);
}
&.toast-info {
border-color: var(--theme-border-color);
background-color: var(--theme-elevation-50);
}
}
}
}

View File

@@ -36,6 +36,16 @@ export const reload = async (config: SanitizedConfig, payload: Payload): Promise
// TODO: support HMR for other props in the future (see payload/src/index init()) hat may change on Payload singleton
// Generate types
if (config.typescript.autoGenerate !== false) {
// We cannot run it directly here, as generate-types imports json-schema-to-typescript, which breaks on turbopack.
// see: https://github.com/vercel/next.js/issues/66723
void payload.bin({
args: ['generate:types'],
log: false,
})
}
await payload.db.init()
if (payload.db.connect) {
await payload.db.connect({ hotReload: true })

View File

@@ -15,7 +15,7 @@ import { useTranslation } from '@payloadcms/ui/providers/Translation'
import { useSearchParams } from 'next/navigation.js'
import qs from 'qs'
import * as React from 'react'
import { toast } from 'react-toastify'
import { toast } from 'sonner'
import { SetDocumentStepNav } from '../Edit/Default/SetDocumentStepNav/index.js'
import { LocaleSelector } from './LocaleSelector/index.js'

View File

@@ -151,8 +151,10 @@ export const Document: React.FC<AdminViewProps> = async ({
hasSavePermission &&
((collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.autosave) ||
(globalConfig?.versions?.drafts && globalConfig?.versions?.drafts?.autosave))
const validateDraftData =
collectionConfig?.versions?.drafts && collectionConfig?.versions?.drafts?.validate
if (shouldAutosave && !id && collectionSlug) {
if (shouldAutosave && !validateDraftData && !id && collectionSlug) {
const doc = await payload.create({
collection: collectionSlug,
data: {},

View File

@@ -10,7 +10,7 @@ import { useConfig } from '@payloadcms/ui/providers/Config'
import { useDocumentInfo } from '@payloadcms/ui/providers/DocumentInfo'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import React, { useCallback, useEffect, useState } from 'react'
import { toast } from 'react-toastify'
import { toast } from 'sonner'
import type { Props } from './types.js'
@@ -71,7 +71,7 @@ export const Auth: React.FC<Props> = (props) => {
})
if (response.status === 200) {
toast.success(t('authentication:successfullyUnlocked'), { autoClose: 3000 })
toast.success(t('authentication:successfullyUnlocked'))
} else {
toast.error(t('authentication:failedToUnlock'))
}

View File

@@ -9,7 +9,7 @@ import { useConfig } from '@payloadcms/ui/providers/Config'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import { email } from 'payload/fields/validations'
import React, { Fragment, useState } from 'react'
import { toast } from 'react-toastify'
import { toast } from 'sonner'
export const ForgotPasswordForm: React.FC = () => {
const config = useConfig()

View File

@@ -97,7 +97,7 @@ export const ListView: React.FC<AdminViewProps> = async ({
const sort =
query?.sort && typeof query.sort === 'string'
? query.sort
: listPreferences?.sort || undefined
: listPreferences?.sort || collectionConfig.defaultSort || undefined
const data = await payload.find({
collection: collectionSlug,

View File

@@ -10,16 +10,20 @@ export const DeviceContainer: React.FC<{
const { children } = props
const deviceFrameRef = React.useRef<HTMLDivElement>(null)
const outerFrameRef = React.useRef<HTMLDivElement>(null)
const { breakpoint, setMeasuredDeviceSize, size, zoom } = useLivePreviewContext()
const { breakpoint, setMeasuredDeviceSize, size: desiredSize, 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)
const { size: measuredDeviceSize } = useResize(deviceFrameRef.current)
const { size: outerFrameSize } = useResize(outerFrameRef.current)
let deviceIsLargerThanFrame: boolean = false
// 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
// its size is freshly populated again upon re-mounting, i.e. going from iframe->popup->iframe
useEffect(() => {
if (measuredDeviceSize) {
setMeasuredDeviceSize(measuredDeviceSize)
@@ -34,13 +38,34 @@ export const DeviceContainer: React.FC<{
if (
typeof zoom === 'number' &&
typeof size.width === 'number' &&
typeof size.height === 'number'
typeof desiredSize.width === 'number' &&
typeof desiredSize.height === 'number' &&
typeof measuredDeviceSize.width === 'number' &&
typeof measuredDeviceSize.height === 'number'
) {
const scaledWidth = size.width / zoom
const difference = scaledWidth - size.width
x = `${difference / 2}px`
margin = '0 auto'
const scaledDesiredWidth = desiredSize.width / zoom
const scaledDeviceWidth = measuredDeviceSize.width * zoom
const scaledDeviceDifferencePixels = scaledDesiredWidth - desiredSize.width
deviceIsLargerThanFrame = scaledDeviceWidth > outerFrameSize.width
if (deviceIsLargerThanFrame) {
if (zoom > 1) {
const differenceFromDeviceToFrame = measuredDeviceSize.width - outerFrameSize.width
if (differenceFromDeviceToFrame < 0) x = `${differenceFromDeviceToFrame / 2}px`
else x = '0'
} else {
x = '0'
}
} else {
if (zoom >= 1) {
x = `${scaledDeviceDifferencePixels / 2}px`
} else {
const differenceFromDeviceToFrame = outerFrameSize.width - scaledDeviceWidth
x = `${differenceFromDeviceToFrame / 2}px`
margin = '0'
}
}
}
}
@@ -48,21 +73,29 @@ export const DeviceContainer: React.FC<{
let height = zoom ? `${100 / zoom}%` : '100%'
if (breakpoint !== 'responsive') {
width = `${size?.width / (typeof zoom === 'number' ? zoom : 1)}px`
height = `${size?.height / (typeof zoom === 'number' ? zoom : 1)}px`
width = `${desiredSize?.width / (typeof zoom === 'number' ? zoom : 1)}px`
height = `${desiredSize?.height / (typeof zoom === 'number' ? zoom : 1)}px`
}
return (
<div
ref={deviceFrameRef}
ref={outerFrameRef}
style={{
height,
margin,
transform: `translate3d(${x}, 0, 0)`,
width,
height: '100%',
width: '100%',
}}
>
{children}
<div
ref={deviceFrameRef}
style={{
height,
margin,
transform: `translate3d(${x}, 0, 0)`,
width,
}}
>
{children}
</div>
</div>
)
}

View File

@@ -11,7 +11,7 @@ import { useConfig } from '@payloadcms/ui/providers/Config'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import { useRouter } from 'next/navigation.js'
import React from 'react'
import { toast } from 'react-toastify'
import { toast } from 'sonner'
type Args = {
token: string
@@ -49,7 +49,7 @@ export const ResetPasswordClient: React.FC<Args> = ({ token }) => {
history.push(`${admin}`)
} else {
history.push(`${admin}/login`)
toast.success(i18n.t('general:updatedSuccessfully'), { autoClose: 3000 })
toast.success(i18n.t('general:updatedSuccessfully'))
}
},
[fetchFullUser, history, admin, i18n],

View File

@@ -9,7 +9,7 @@ import { MinimalTemplate } from '@payloadcms/ui/templates/Minimal'
import { requests } from '@payloadcms/ui/utilities/api'
import { useRouter } from 'next/navigation.js'
import React, { Fragment, useCallback, useState } from 'react'
import { toast } from 'react-toastify'
import { toast } from 'sonner'
import type { Props } from './types.js'

View File

@@ -28,6 +28,13 @@ export const withPayload = (nextConfig = {}) => {
'libsql',
],
},
turbo: {
...(nextConfig?.experimental?.turbo || {}),
resolveAlias: {
...(nextConfig?.experimental?.turbo?.resolveAlias || {}),
'payload-mock-package': 'payload-mock-package',
},
},
},
headers: async () => {
const headersFromConfig = 'headers' in nextConfig ? await nextConfig.headers() : []

View File

@@ -4,8 +4,6 @@ import { register } from 'node:module'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { bin } from './dist/bin/index.js'
// Allow disabling SWC for debugging
if (process.env.DISABLE_SWC !== 'true') {
const filename = fileURLToPath(import.meta.url)
@@ -15,4 +13,9 @@ if (process.env.DISABLE_SWC !== 'true') {
register('./dist/bin/loader/index.js', url)
}
bin()
const start = async () => {
const { bin } = await import('./dist/bin/index.js')
bin()
}
void start()

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",

View File

@@ -2,8 +2,10 @@ import type { GenericLanguages, I18n, I18nClient } from '@payloadcms/translation
import type { JSONSchema4 } from 'json-schema'
import type React from 'react'
import type { SanitizedCollectionConfig, TypeWithID } from '../collections/config/types.js'
import type { SanitizedConfig } from '../config/types.js'
import type { Field, FieldBase, RichTextField, Validate } from '../fields/config/types.js'
import type { Field, FieldAffectingData, RichTextField, Validate } from '../fields/config/types.js'
import type { SanitizedGlobalConfig } from '../globals/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../types/index.js'
import type { WithServerSidePropsComponentProps } from './elements/WithServerSideProps.js'
@@ -15,6 +17,173 @@ export type RichTextFieldProps<
path?: string
}
export type AfterReadRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = {
currentDepth?: number
depth?: number
draft?: boolean
fallbackLocale?: string
fieldPromises?: Promise<void>[]
/** Boolean to denote if this hook is running against finding one, or finding many within the afterRead hook. */
findMany?: boolean
flattenLocales?: boolean
locale?: string
/** A string relating to which operation the field type is currently executing within. */
operation?: 'create' | 'delete' | 'read' | 'update'
overrideAccess?: boolean
populationPromises?: Promise<void>[]
showHiddenFields?: boolean
triggerAccessControl?: boolean
triggerHooks?: boolean
}
export type AfterChangeRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = {
/** A string relating to which operation the field type is currently executing within. */
operation: 'create' | 'update'
/** The document before changes were applied. */
previousDoc?: TData
/** The sibling data of the document before changes being applied. */
previousSiblingDoc?: TData
/** The previous value of the field, before changes */
previousValue?: TValue
}
export type BeforeValidateRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = {
/** A string relating to which operation the field type is currently executing within. */
operation: 'create' | 'update'
overrideAccess?: boolean
/** The sibling data of the document before changes being applied. */
previousSiblingDoc?: TData
/** The previous value of the field, before changes */
previousValue?: TValue
}
export type BeforeChangeRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = {
/**
* The original data with locales (not modified by any hooks). Only available in `beforeChange` and `beforeDuplicate` field hooks.
*/
docWithLocales?: Record<string, unknown>
duplicate?: boolean
errors?: { field: string; message: string }[]
/** Only available in `beforeChange` field hooks */
mergeLocaleActions?: (() => Promise<void>)[]
/** A string relating to which operation the field type is currently executing within. */
operation?: 'create' | 'delete' | 'read' | 'update'
/** The sibling data of the document before changes being applied. */
previousSiblingDoc?: TData
/** The previous value of the field, before changes */
previousValue?: TValue
/**
* The original siblingData with locales (not modified by any hooks).
*/
siblingDocWithLocales?: Record<string, unknown>
skipValidation?: boolean
}
export type BaseRichTextHookArgs<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = {
/** The collection which the field belongs to. If the field belongs to a global, this will be null. */
collection: SanitizedCollectionConfig | null
context: RequestContext
/** The data passed to update the document within create and update operations, and the full document itself in the afterRead hook. */
data?: Partial<TData>
/** The field which the hook is running against. */
field: FieldAffectingData
/** The global which the field belongs to. If the field belongs to a collection, this will be null. */
global: SanitizedGlobalConfig | null
/** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */
originalDoc?: TData
/**
* The path of the field, e.g. ["group", "myArray", 1, "textField"]. The path is the schemaPath but with indexes and would be used in the context of field data, not field schemas.
*/
path: (number | string)[]
/** The Express request object. It is mocked for Local API operations. */
req: PayloadRequestWithData
/**
* The schemaPath of the field, e.g. ["group", "myArray", "textField"]. The schemaPath is the path but without indexes and would be used in the context of field schemas, not field data.
*/
schemaPath: string[]
/** The sibling data passed to a field that the hook is running against. */
siblingData: Partial<TSiblingData>
/** The value of the field. */
value?: TValue
}
export type AfterReadRichTextHook<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = (
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
AfterReadRichTextHookArgs<TData, TValue, TSiblingData>,
) => Promise<TValue> | TValue
export type AfterChangeRichTextHook<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = (
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
AfterChangeRichTextHookArgs<TData, TValue, TSiblingData>,
) => Promise<TValue> | TValue
export type BeforeChangeRichTextHook<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = (
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
BeforeChangeRichTextHookArgs<TData, TValue, TSiblingData>,
) => Promise<TValue> | TValue
export type BeforeValidateRichTextHook<
TData extends TypeWithID = any,
TValue = any,
TSiblingData = any,
> = (
args: BaseRichTextHookArgs<TData, TValue, TSiblingData> &
BeforeValidateRichTextHookArgs<TData, TValue, TSiblingData>,
) => Promise<TValue> | TValue
export type RichTextHooks = {
afterChange?: AfterChangeRichTextHook[]
afterRead?: AfterReadRichTextHook[]
beforeChange?: BeforeChangeRichTextHook[]
beforeValidate?: BeforeValidateRichTextHook[]
}
type RichTextAdapterBase<
Value extends object = object,
AdapterProps = any,
@@ -32,7 +201,28 @@ type RichTextAdapterBase<
schemaMap: Map<string, Field[]>
schemaPath: string
}) => Map<string, Field[]>
hooks?: FieldBase['hooks']
/**
* Like an afterRead hook, but runs only for the GraphQL resolver. For populating data, this should be used, as afterRead hooks do not have a depth in graphQL.
*
* To populate stuff / resolve field hooks, mutate the incoming populationPromises or fieldPromises array. They will then be awaited in the correct order within payload itself.
* @param data
*/
graphQLPopulationPromises?: (data: {
context: RequestContext
currentDepth?: number
depth: number
draft: boolean
field: RichTextField<Value, AdapterProps, ExtraFieldProperties>
fieldPromises: Promise<void>[]
findMany: boolean
flattenLocales: boolean
overrideAccess?: boolean
populationPromises: Promise<void>[]
req: PayloadRequestWithData
showHiddenFields: boolean
siblingDoc: Record<string, unknown>
}) => void
hooks?: RichTextHooks
i18n?: Partial<GenericLanguages>
outputSchema?: ({
collectionIDFieldTypes,
@@ -50,27 +240,6 @@ type RichTextAdapterBase<
interfaceNameDefinitions: Map<string, JSONSchema4>
isRequired: boolean
}) => JSONSchema4
/**
* Like an afterRead hook, but runs for both afterRead AND in the GraphQL resolver. For populating data, this should be used.
*
* To populate stuff / resolve field hooks, mutate the incoming populationPromises or fieldPromises array. They will then be awaited in the correct order within payload itself.
* @param data
*/
populationPromises?: (data: {
context: RequestContext
currentDepth?: number
depth: number
draft: boolean
field: RichTextField<Value, AdapterProps, ExtraFieldProperties>
fieldPromises: Promise<void>[]
findMany: boolean
flattenLocales: boolean
overrideAccess?: boolean
populationPromises: Promise<void>[]
req: PayloadRequestWithData
showHiddenFields: boolean
siblingDoc: Record<string, unknown>
}) => void
validate: Validate<
Value,
Value,

View File

@@ -129,8 +129,7 @@ export const forgotPasswordOperation = async (incomingArgs: Arguments): Promise<
})
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
email.sendEmail({
await email.sendEmail({
from: `"${email.defaultFromName}" <${email.defaultFromAddress}>`,
html,
subject,

View File

@@ -64,8 +64,7 @@ export async function sendVerificationEmail(args: Args): Promise<void> {
})
}
// eslint-disable-next-line @typescript-eslint/no-floating-promises
email.sendEmail({
await email.sendEmail({
from: `"${email.defaultFromName}" <${email.defaultFromAddress}>`,
html,
subject,

View File

@@ -6,11 +6,16 @@ import type { SanitizedConfig } from '../config/types.js'
import { configToJSONSchema } from '../utilities/configToJSONSchema.js'
import Logger from '../utilities/logger.js'
export async function generateTypes(config: SanitizedConfig): Promise<void> {
export async function generateTypes(
config: SanitizedConfig,
options?: { log: boolean },
): Promise<void> {
const logger = Logger()
const outputFile = process.env.PAYLOAD_TS_OUTPUT_PATH || config.typescript.outputFile
logger.info('Compiling TS types for Collections and Globals...')
const shouldLog = options?.log ?? true
if (shouldLog) logger.info('Compiling TS types for Collections and Globals...')
const jsonSchema = configToJSONSchema(config, config.db.defaultIDType)
@@ -36,6 +41,18 @@ export async function generateTypes(config: SanitizedConfig): Promise<void> {
compiled += `\n\n${declare}`
}
}
// Diff the compiled types against the existing types file
try {
const existingTypes = fs.readFileSync(outputFile, 'utf-8')
if (compiled === existingTypes) {
return
}
} catch (_) {
// swallow err
}
fs.writeFileSync(outputFile, compiled)
logger.info(`Types written to ${outputFile}`)
if (shouldLog) logger.info(`Types written to ${outputFile}`)
}

View File

@@ -15,6 +15,7 @@ import { getBaseUploadFields } from '../../uploads/getBaseFields.js'
import { formatLabels } from '../../utilities/formatLabels.js'
import { isPlainObject } from '../../utilities/isPlainObject.js'
import baseVersionFields from '../../versions/baseFields.js'
import { versionDefaults } from '../../versions/defaults.js'
import { authDefaults, defaults } from './defaults.js'
export const sanitizeCollection = async (
@@ -84,15 +85,20 @@ export const sanitizeCollection = async (
if (sanitized.versions.drafts === true) {
sanitized.versions.drafts = {
autosave: false,
validate: false,
}
}
if (sanitized.versions.drafts.autosave === true) {
sanitized.versions.drafts.autosave = {
interval: 2000,
interval: versionDefaults.autosaveInterval,
}
}
if (sanitized.versions.drafts.validate === undefined) {
sanitized.versions.drafts.validate = false
}
sanitized.fields = mergeBaseFields(sanitized.fields, baseVersionFields)
}
}

View File

@@ -221,6 +221,7 @@ const collectionSchema = joi.object().keys({
interval: joi.number(),
}),
),
validate: joi.boolean(),
}),
joi.boolean(),
),

View File

@@ -165,14 +165,6 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
Promise.resolve(),
)
// /////////////////////////////////////
// Write files to local storage
// /////////////////////////////////////
// if (!collectionConfig.upload.disableLocalStorage) {
// await uploadFiles(payload, filesToUpload, req.t)
// }
// /////////////////////////////////////
// beforeChange - Collection
// /////////////////////////////////////
@@ -203,7 +195,10 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
global: null,
operation: 'create',
req,
skipValidation: shouldSaveDraft,
skipValidation:
shouldSaveDraft &&
collectionConfig.versions.drafts &&
!collectionConfig.versions.drafts.validate,
})
// /////////////////////////////////////
@@ -268,8 +263,7 @@ export const createOperation = async <TSlug extends keyof GeneratedTypes['collec
// /////////////////////////////////////
if (collectionConfig.auth && collectionConfig.auth.verify) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
sendVerificationEmail({
await sendVerificationEmail({
collection: { config: collectionConfig },
config: payload.config,
disableEmail: disableVerificationEmail,

View File

@@ -205,7 +205,10 @@ export const duplicateOperation = async <TSlug extends keyof GeneratedTypes['col
global: null,
operation,
req,
skipValidation: shouldSaveDraft,
skipValidation:
shouldSaveDraft &&
collectionConfig.versions.drafts &&
!collectionConfig.versions.drafts.validate,
})
// set req.locale back to the original locale

View File

@@ -270,7 +270,10 @@ export const updateOperation = async <TSlug extends keyof GeneratedTypes['collec
operation: 'update',
req,
skipValidation:
Boolean(collectionConfig.versions?.drafts) && data._status !== 'published',
shouldSaveDraft &&
collectionConfig.versions.drafts &&
!collectionConfig.versions.drafts.validate &&
data._status !== 'published',
})
// /////////////////////////////////////

View File

@@ -242,7 +242,11 @@ export const updateByIDOperation = async <TSlug extends keyof GeneratedTypes['co
global: null,
operation: 'update',
req,
skipValidation: Boolean(collectionConfig.versions?.drafts) && data._status !== 'published',
skipValidation:
shouldSaveDraft &&
collectionConfig.versions.drafts &&
!collectionConfig.versions.drafts.validate &&
data._status !== 'published',
})
// /////////////////////////////////////

View File

@@ -49,6 +49,7 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
serverURL: '',
telemetry: true,
typescript: {
autoGenerate: true,
outputFile: `${typeof process?.cwd === 'function' ? process.cwd() : ''}/payload-types.ts`,
},
upload: {},

View File

@@ -1,5 +1,5 @@
import { findUpSync, pathExistsSync } from 'find-up'
import fs from 'fs'
import { getTsconfig } from 'get-tsconfig'
import path from 'path'
/**
@@ -12,37 +12,30 @@ const getTSConfigPaths = (): {
outPath?: string
rootPath?: string
srcPath?: string
tsConfigPath?: string
} => {
const tsConfigPath = findUpSync('tsconfig.json')
if (!tsConfigPath) {
return {
rootPath: process.cwd(),
}
}
const tsConfigResult = getTsconfig()
const tsConfig = tsConfigResult.config
const tsConfigDir = path.dirname(tsConfigResult.path)
try {
// Read the file as a string and remove trailing commas
const rawTsConfig = fs
.readFileSync(tsConfigPath, 'utf-8')
.replace(/,\s*\]/g, ']')
.replace(/,\s*\}/g, '}')
const tsConfig = JSON.parse(rawTsConfig)
const rootPath = process.cwd()
const rootConfigDir = path.resolve(tsConfigDir, tsConfig.compilerOptions.baseUrl || '')
const srcPath = tsConfig.compilerOptions?.rootDir || path.resolve(process.cwd(), 'src')
const outPath = tsConfig.compilerOptions?.outDir || path.resolve(process.cwd(), 'dist')
const tsConfigDir = path.dirname(tsConfigPath)
let configPath = tsConfig.compilerOptions?.paths?.['@payload-config']?.[0]
let configPath = path.resolve(
rootConfigDir,
tsConfig.compilerOptions?.paths?.['@payload-config']?.[0],
)
if (configPath) {
configPath = path.resolve(tsConfigDir, configPath)
configPath = path.resolve(rootConfigDir, configPath)
}
return {
configPath,
outPath,
rootPath,
rootPath: rootConfigDir,
srcPath,
tsConfigPath: tsConfigResult.path,
}
} catch (error) {
console.error(`Error parsing tsconfig.json: ${error}`) // Do not throw the error, as we can still continue with the other config path finding methods
@@ -70,6 +63,11 @@ export const findConfig = (): string => {
const { configPath, outPath, rootPath, srcPath } = getTSConfigPaths()
// if configPath is absolute file, not folder, return it
if (path.extname(configPath) === '.js' || path.extname(configPath) === '.ts') {
return configPath
}
const searchPaths =
process.env.NODE_ENV === 'production'
? [configPath, outPath, srcPath, rootPath]

View File

@@ -189,6 +189,7 @@ export default joi.object({
sharp: joi.any(),
telemetry: joi.boolean(),
typescript: joi.object({
autoGenerate: joi.boolean(),
declare: joi.alternatives().try(joi.boolean(), joi.object({ ignoreTSError: joi.boolean() })),
outputFile: joi.string(),
}),

View File

@@ -719,6 +719,12 @@ export type Config = {
telemetry?: boolean
/** Control how typescript interfaces are generated from your collections. */
typescript?: {
/**
* Automatically generate types during development
* @default true
*/
autoGenerate?: boolean
/** Disable declare block in generated types file */
declare?:
| {
@@ -732,6 +738,7 @@ export type Config = {
ignoreTSError?: boolean
}
| false
/** Filename to write the generated types to */
outputFile?: string
}

View File

@@ -1,5 +1,6 @@
export { buildVersionCollectionFields } from '../versions/buildCollectionFields.js'
export { buildVersionGlobalFields } from '../versions/buildGlobalFields.js'
export { versionDefaults } from '../versions/defaults.js'
export { deleteCollectionVersions } from '../versions/deleteCollectionVersions.js'
export { enforceMaxVersions } from '../versions/enforceMaxVersions.js'
export { getLatestCollectionVersion } from '../versions/getLatestCollectionVersion.js'

View File

@@ -172,25 +172,6 @@ export const sanitizeFields = async ({
if (field.editor.i18n && Object.keys(field.editor.i18n).length >= 0) {
config.i18n.translations = deepMerge(config.i18n.translations, field.editor.i18n)
}
// Add editor adapter hooks to field hooks
if (!field.hooks) field.hooks = {}
const mergeHooks = (hookName: keyof typeof field.editor.hooks) => {
if (typeof field.editor === 'function') return
if (field.editor?.hooks?.[hookName]?.length) {
field.hooks[hookName] = field.hooks[hookName]
? field.hooks[hookName].concat(field.editor.hooks[hookName])
: [...field.editor.hooks[hookName]]
}
}
mergeHooks('afterRead')
mergeHooks('afterChange')
mergeHooks('beforeChange')
mergeHooks('beforeValidate')
mergeHooks('beforeDuplicate')
}
if (richTextSanitizationPromises) {
richTextSanitizationPromises.push(sanitizeRichText)

View File

@@ -499,8 +499,8 @@ export const richText = baseField.keys({
CellComponent: componentSchema.optional(),
FieldComponent: componentSchema.optional(),
afterReadPromise: joi.func().optional(),
graphQLPopulationPromises: joi.func().optional(),
outputSchema: joi.func().optional(),
populationPromise: joi.func().optional(),
validate: joi.func().required(),
})
.unknown(),

View File

@@ -41,16 +41,28 @@ export type FieldHookArgs<TData extends TypeWithID = any, TValue = any, TSibling
/** The full original document in `update` operations. In the `afterChange` hook, this is the resulting document of the operation. */
originalDoc?: TData
overrideAccess?: boolean
/**
* The path of the field, e.g. ["group", "myArray", 1, "textField"]. The path is the schemaPath but with indexes and would be used in the context of field data, not field schemas.
*/
path: (number | string)[]
/** The document before changes were applied, only in `afterChange` hooks. */
previousDoc?: TData
/** The sibling data of the document before changes being applied, only in `beforeChange` and `afterChange` hook. */
/** The sibling data of the document before changes being applied, only in `beforeChange`, `beforeValidate`, `beforeDuplicate` and `afterChange` field hooks. */
previousSiblingDoc?: TData
/** The previous value of the field, before changes, only in `beforeChange`, `afterChange` and `beforeValidate` hooks. */
/** The previous value of the field, before changes, only in `beforeChange`, `afterChange`, `beforeDuplicate` and `beforeValidate` field hooks. */
previousValue?: TValue
/** The Express request object. It is mocked for Local API operations. */
req: PayloadRequestWithData
/**
* The schemaPath of the field, e.g. ["group", "myArray", "textField"]. The schemaPath is the path but without indexes and would be used in the context of field schemas, not field data.
*/
schemaPath: string[]
/** The sibling data passed to a field that the hook is running against. */
siblingData: Partial<TSiblingData>
/**
* The original siblingData with locales (not modified by any hooks). Only available in `beforeChange` and `beforeDuplicate` field hooks.
*/
siblingDocWithLocales?: Record<string, unknown>
/** The value of the field. */
value?: TValue
}

View File

@@ -0,0 +1,39 @@
import type { Field, TabAsField } from './config/types.js'
import { tabHasName } from './config/types.js'
export function getFieldPaths({
field,
parentPath,
parentSchemaPath,
}: {
field: Field | TabAsField
parentPath: (number | string)[]
parentSchemaPath: string[]
}): {
path: (number | string)[]
schemaPath: string[]
} {
if (field.type === 'tabs' || field.type === 'row' || field.type === 'collapsible') {
return {
path: parentPath,
schemaPath: parentSchemaPath,
}
} else if (field.type === 'tab') {
if (tabHasName(field)) {
return {
path: [...parentPath, field.name],
schemaPath: [...parentSchemaPath, field.name],
}
} else {
return {
path: parentPath,
schemaPath: parentSchemaPath,
}
}
}
const path = parentPath?.length ? [...parentPath, field.name] : [field.name]
const schemaPath = parentSchemaPath?.length ? [...parentSchemaPath, field.name] : [field.name]
return { path, schemaPath }
}

View File

@@ -8,7 +8,13 @@ import { traverseFields } from './traverseFields.js'
type Args<T> = {
collection: SanitizedCollectionConfig | null
context: RequestContext
/**
* The data before hooks
*/
data: Record<string, unknown> | T
/**
* The data after hooks
*/
doc: Record<string, unknown> | T
global: SanitizedGlobalConfig | null
operation: 'create' | 'update'
@@ -24,7 +30,6 @@ export const afterChange = async <T extends Record<string, unknown>>({
collection,
context,
data,
doc: incomingDoc,
global,
operation,
@@ -41,9 +46,11 @@ export const afterChange = async <T extends Record<string, unknown>>({
fields: collection?.fields || global?.fields,
global,
operation,
path: [],
previousDoc,
previousSiblingDoc: previousDoc,
req,
schemaPath: [],
siblingData: data,
siblingDoc: doc,
})

View File

@@ -1,10 +1,13 @@
/* eslint-disable no-param-reassign */
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { traverseFields } from './traverseFields.js'
type Args = {
@@ -15,6 +18,14 @@ type Args = {
field: Field | TabAsField
global: SanitizedGlobalConfig | null
operation: 'create' | 'update'
/**
* The parent's path
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
previousDoc: Record<string, unknown>
previousSiblingDoc: Record<string, unknown>
req: PayloadRequestWithData
@@ -33,12 +44,20 @@ export const promise = async ({
field,
global,
operation,
parentPath,
parentSchemaPath,
previousDoc,
previousSiblingDoc,
req,
siblingData,
siblingDoc,
}: Args): Promise<void> => {
const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({
field,
parentPath,
parentSchemaPath,
})
if (fieldAffectsData(field)) {
// Execute hooks
if (field.hooks?.afterChange) {
@@ -53,12 +72,14 @@ export const promise = async ({
global,
operation,
originalDoc: doc,
path: fieldPath,
previousDoc,
previousSiblingDoc,
previousValue: previousDoc[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingData[field.name],
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
@@ -79,9 +100,11 @@ export const promise = async ({
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: previousDoc[field.name] as Record<string, unknown>,
req,
schemaPath: fieldSchemaPath,
siblingData: (siblingData?.[field.name] as Record<string, unknown>) || {},
siblingDoc: siblingDoc[field.name] as Record<string, unknown>,
})
@@ -104,9 +127,11 @@ export const promise = async ({
fields: field.fields,
global,
operation,
path: [...fieldPath, i],
previousDoc,
previousSiblingDoc: previousDoc?.[field.name]?.[i] || ({} as Record<string, unknown>),
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData?.[field.name]?.[i] || {},
siblingDoc: { ...row } || {},
}),
@@ -135,10 +160,12 @@ export const promise = async ({
fields: block.fields,
global,
operation,
path: [...fieldPath, i],
previousDoc,
previousSiblingDoc:
previousDoc?.[field.name]?.[i] || ({} as Record<string, unknown>),
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData?.[field.name]?.[i] || {},
siblingDoc: { ...row } || {},
}),
@@ -161,9 +188,11 @@ export const promise = async ({
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: { ...previousSiblingDoc },
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData || {},
siblingDoc: { ...siblingDoc },
})
@@ -190,9 +219,11 @@ export const promise = async ({
fields: field.fields,
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: tabPreviousSiblingDoc,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
})
@@ -209,15 +240,57 @@ export const promise = async ({
fields: field.tabs.map((tab) => ({ ...tab, type: 'tab' })),
global,
operation,
path: fieldPath,
previousDoc,
previousSiblingDoc: { ...previousSiblingDoc },
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData || {},
siblingDoc: { ...siblingDoc },
})
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.afterChange?.length) {
await editor.hooks.afterChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
path: fieldPath,
previousDoc,
previousSiblingDoc,
previousValue: previousDoc[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
default: {
break
}

View File

@@ -13,9 +13,11 @@ type Args = {
fields: (Field | TabAsField)[]
global: SanitizedGlobalConfig | null
operation: 'create' | 'update'
path: (number | string)[]
previousDoc: Record<string, unknown>
previousSiblingDoc: Record<string, unknown>
req: PayloadRequestWithData
schemaPath: string[]
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
}
@@ -28,9 +30,11 @@ export const traverseFields = async ({
fields,
global,
operation,
path,
previousDoc,
previousSiblingDoc,
req,
schemaPath,
siblingData,
siblingDoc,
}: Args): Promise<void> => {
@@ -46,6 +50,8 @@ export const traverseFields = async ({
field,
global,
operation,
parentPath: path,
parentSchemaPath: schemaPath,
previousDoc,
previousSiblingDoc,
req,

View File

@@ -25,7 +25,7 @@ type Args = {
/**
* This function is responsible for the following actions, in order:
* - Remove hidden fields from response
* - Flatten locales into requested locale
* - Flatten locales into requested locale. If the input doc contains all locales, the output doc after this function will only contain the requested locale.
* - Sanitize outgoing data (point field, etc.)
* - Execute field hooks
* - Execute read access control
@@ -77,8 +77,10 @@ export async function afterRead<T = any>(args: Args): Promise<T> {
global,
locale,
overrideAccess,
path: [],
populationPromises,
req,
schemaPath: [],
showHiddenFields,
siblingDoc: doc,
})

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-param-reassign */
import type { RichTextAdapter } from '../../../admin/types.js'
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../../../types/index.js'
@@ -8,6 +8,7 @@ import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import getValueWithDefault from '../../getDefaultValue.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { relationshipPopulationPromise } from './relationshipPopulationPromise.js'
import { traverseFields } from './traverseFields.js'
@@ -29,6 +30,14 @@ type Args = {
global: SanitizedGlobalConfig | null
locale: null | string
overrideAccess: boolean
/**
* The parent's path.
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
populationPromises: Promise<void>[]
req: PayloadRequestWithData
showHiddenFields: boolean
@@ -60,6 +69,8 @@ export const promise = async ({
global,
locale,
overrideAccess,
parentPath,
parentSchemaPath,
populationPromises,
req,
showHiddenFields,
@@ -67,6 +78,12 @@ export const promise = async ({
triggerAccessControl = true,
triggerHooks = true,
}: Args): Promise<void> => {
const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({
field,
parentPath,
parentSchemaPath,
})
if (
fieldAffectsData(field) &&
field.hidden &&
@@ -151,29 +168,7 @@ export const promise = async ({
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
// This is run here AND in the GraphQL Resolver
if (editor?.populationPromises) {
const populateDepth =
field?.maxDepth !== undefined && field?.maxDepth < depth ? field?.maxDepth : depth
editor.populationPromises({
context,
currentDepth,
depth: populateDepth,
draft,
field,
fieldPromises,
findMany,
flattenLocales,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
})
}
// Rich Text fields should use afterRead hooks to do population. The previous editor.populationPromises have been renamed to editor.graphQLPopulationPromises
break
}
@@ -212,10 +207,14 @@ export const promise = async ({
context,
data: doc,
field,
findMany,
global,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: siblingDoc,
value,
})
@@ -238,7 +237,9 @@ export const promise = async ({
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: siblingDoc,
value: siblingDoc[field.name],
})
@@ -322,8 +323,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: groupDoc,
triggerAccessControl,
@@ -337,7 +340,7 @@ export const promise = async ({
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
rows.forEach((row) => {
rows.forEach((row, i) => {
traverseFields({
collection,
context,
@@ -353,8 +356,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: row || {},
triggerAccessControl,
@@ -364,7 +369,7 @@ export const promise = async ({
} else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) {
Object.values(rows).forEach((localeRows) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row) => {
localeRows.forEach((row, i) => {
traverseFields({
collection,
context,
@@ -380,8 +385,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: row || {},
triggerAccessControl,
@@ -400,7 +407,7 @@ export const promise = async ({
const rows = siblingDoc[field.name]
if (Array.isArray(rows)) {
rows.forEach((row) => {
rows.forEach((row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType)
if (block) {
@@ -419,8 +426,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: row || {},
triggerAccessControl,
@@ -431,7 +440,7 @@ export const promise = async ({
} else if (!shouldHoistLocalizedValue && typeof rows === 'object' && rows !== null) {
Object.values(rows).forEach((localeRows) => {
if (Array.isArray(localeRows)) {
localeRows.forEach((row) => {
localeRows.forEach((row, i) => {
const block = field.blocks.find((blockType) => blockType.slug === row.blockType)
if (block) {
@@ -450,8 +459,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: [...fieldPath, i],
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: row || {},
triggerAccessControl,
@@ -485,8 +496,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc,
triggerAccessControl,
@@ -518,8 +531,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc: tabDoc,
triggerAccessControl,
@@ -545,8 +560,10 @@ export const promise = async ({
global,
locale,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingDoc,
triggerAccessControl,
@@ -555,6 +572,101 @@ export const promise = async ({
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.afterRead?.length) {
await editor.hooks.afterRead.reduce(async (priorHook, currentHook) => {
await priorHook
const shouldRunHookOnAllLocales =
field.localized &&
(locale === 'all' || !flattenLocales) &&
typeof siblingDoc[field.name] === 'object'
if (shouldRunHookOnAllLocales) {
const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) =>
(async () => {
const hookedValue = await currentHook({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
fallbackLocale,
field,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
triggerAccessControl,
triggerHooks,
value,
})
if (hookedValue !== undefined) {
siblingDoc[field.name][locale] = hookedValue
}
})(),
)
await Promise.all(hookPromises)
} else {
const hookedValue = await currentHook({
collection,
context,
currentDepth,
data: doc,
depth,
draft,
fallbackLocale,
field,
fieldPromises,
findMany,
flattenLocales,
global,
locale,
operation: 'read',
originalDoc: doc,
overrideAccess,
path: fieldPath,
populationPromises,
req,
schemaPath: fieldSchemaPath,
showHiddenFields,
siblingData: siblingDoc,
triggerAccessControl,
triggerHooks,
value: siblingDoc[field.name],
})
if (hookedValue !== undefined) {
siblingDoc[field.name] = hookedValue
}
}
}, Promise.resolve())
}
break
}
default: {
break
}

View File

@@ -23,8 +23,10 @@ type Args = {
global: SanitizedGlobalConfig | null
locale: null | string
overrideAccess: boolean
path: (number | string)[]
populationPromises: Promise<void>[]
req: PayloadRequestWithData
schemaPath: string[]
showHiddenFields: boolean
siblingDoc: Record<string, unknown>
triggerAccessControl?: boolean
@@ -46,8 +48,10 @@ export const traverseFields = ({
global,
locale,
overrideAccess,
path,
populationPromises,
req,
schemaPath,
showHiddenFields,
siblingDoc,
triggerAccessControl = true,
@@ -70,6 +74,8 @@ export const traverseFields = ({
global,
locale,
overrideAccess,
parentPath: path,
parentSchemaPath: schemaPath,
populationPromises,
req,
showHiddenFields,

View File

@@ -27,7 +27,7 @@ type Args<T> = {
* - Validate data
* - Transform data for storage
* - beforeDuplicate hooks (if duplicate)
* - Unflatten locales
* - Unflatten locales. The input `data` is the normal document for one locale. The output result will become the document with locales.
*/
export const beforeChange = async <T extends Record<string, unknown>>({
id,
@@ -59,8 +59,9 @@ export const beforeChange = async <T extends Record<string, unknown>>({
global,
mergeLocaleActions,
operation,
path: '',
path: [],
req,
schemaPath: [],
siblingData: data,
siblingDoc: doc,
siblingDocWithLocales: docWithLocales,

View File

@@ -1,11 +1,14 @@
import merge from 'deepmerge'
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { Operation, PayloadRequestWithData, RequestContext } from '../../../types/index.js'
import type { Field, FieldHookArgs, TabAsField, ValidateOptions } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName } from '../../config/types.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { beforeDuplicate } from './beforeDuplicate.js'
import { getExistingRowDoc } from './getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
@@ -23,7 +26,14 @@ type Args = {
id?: number | string
mergeLocaleActions: (() => Promise<void>)[]
operation: Operation
path: string
/**
* The parent's path.
*/
parentPath: (number | string)[]
/**
* The parent's schemaPath (path without indexes).
*/
parentSchemaPath: string[]
req: PayloadRequestWithData
siblingData: Record<string, unknown>
siblingDoc: Record<string, unknown>
@@ -52,7 +62,8 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path,
parentPath,
parentSchemaPath,
req,
siblingData,
siblingDoc,
@@ -67,6 +78,12 @@ export const promise = async ({
const defaultLocale = localization ? localization?.defaultLocale : 'en'
const operationLocale = req.locale || defaultLocale
const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({
field,
parentPath,
parentSchemaPath,
})
if (fieldAffectsData(field)) {
// skip validation if the field is localized and the incoming data is null
if (field.localized && operationLocale !== defaultLocale) {
@@ -88,10 +105,13 @@ export const promise = async ({
global,
operation,
originalDoc: doc,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData,
siblingDocWithLocales,
value: siblingData[field.name],
})
@@ -127,7 +147,7 @@ export const promise = async ({
if (typeof validationResult === 'string') {
errors.push({
field: `${path}${field.name}`,
field: fieldPath.join('.'),
message: validationResult,
})
}
@@ -139,8 +159,13 @@ export const promise = async ({
data,
field,
global: undefined,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData,
siblingDocWithLocales,
value: siblingData[field.name],
}
@@ -225,8 +250,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path: `${path}${field.name}.`,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: siblingData[field.name] as Record<string, unknown>,
siblingDoc: siblingDoc[field.name] as Record<string, unknown>,
siblingDocWithLocales: siblingDocWithLocales[field.name] as Record<string, unknown>,
@@ -256,8 +282,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path: `${path}${field.name}.${i}.`,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row,
siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]),
siblingDocWithLocales: getExistingRowDoc(row, siblingDocWithLocales[field.name]),
@@ -299,8 +326,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path: `${path}${field.name}.${i}.`,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row,
siblingDoc: rowSiblingDoc,
siblingDocWithLocales: rowSiblingDocWithLocales,
@@ -331,8 +359,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
siblingDocWithLocales,
@@ -343,13 +372,11 @@ export const promise = async ({
}
case 'tab': {
let tabPath = path
let tabSiblingData = siblingData
let tabSiblingDoc = siblingDoc
let tabSiblingDocWithLocales = siblingDocWithLocales
if (tabHasName(field)) {
tabPath = `${path}${field.name}.`
if (typeof siblingData[field.name] !== 'object') siblingData[field.name] = {}
if (typeof siblingDoc[field.name] !== 'object') siblingDoc[field.name] = {}
if (typeof siblingDocWithLocales[field.name] !== 'object')
@@ -373,8 +400,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path: tabPath,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
siblingDocWithLocales: tabSiblingDocWithLocales,
@@ -398,8 +426,9 @@ export const promise = async ({
global,
mergeLocaleActions,
operation,
path,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
siblingDocWithLocales,
@@ -409,6 +438,52 @@ export const promise = async ({
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.beforeChange?.length) {
await editor.hooks.beforeChange.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
docWithLocales,
duplicate,
errors,
field,
global,
mergeLocaleActions,
operation,
originalDoc: doc,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingDoc[field.name],
req,
schemaPath: parentSchemaPath,
siblingData,
siblingDocWithLocales,
skipValidation,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
default: {
break
}

View File

@@ -24,8 +24,9 @@ type Args = {
id?: number | string
mergeLocaleActions: (() => Promise<void>)[]
operation: Operation
path: string
path: (number | string)[]
req: PayloadRequestWithData
schemaPath: string[]
siblingData: Record<string, unknown>
/**
* The original siblingData (not modified by any hooks)
@@ -44,7 +45,7 @@ type Args = {
* - Execute field hooks
* - Validate data
* - Transform data for storage
* - Unflatten locales
* - Unflatten locales. The input `data` is the normal document for one locale. The output result will become the document with locales.
*/
export const traverseFields = async ({
id,
@@ -61,6 +62,7 @@ export const traverseFields = async ({
operation,
path,
req,
schemaPath,
siblingData,
siblingDoc,
siblingDocWithLocales,
@@ -83,7 +85,8 @@ export const traverseFields = async ({
global,
mergeLocaleActions,
operation,
path,
parentPath: path,
parentSchemaPath: schemaPath,
req,
siblingData,
siblingDoc,

View File

@@ -49,7 +49,9 @@ export const beforeValidate = async <T extends Record<string, unknown>>({
global,
operation,
overrideAccess,
path: [],
req,
schemaPath: [],
siblingData: data,
siblingDoc: doc,
})

View File

@@ -1,11 +1,14 @@
/* eslint-disable no-param-reassign */
import type { RichTextAdapter } from '../../../admin/RichText.js'
import type { SanitizedCollectionConfig } from '../../../collections/config/types.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { fieldAffectsData, tabHasName, valueIsValueWithRelation } from '../../config/types.js'
import getValueWithDefault from '../../getDefaultValue.js'
import { getFieldPaths } from '../../getFieldPaths.js'
import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc.js'
import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
@@ -23,6 +26,8 @@ type Args<T> = {
id?: number | string
operation: 'create' | 'update'
overrideAccess: boolean
parentPath: (number | string)[]
parentSchemaPath: string[]
req: PayloadRequestWithData
siblingData: Record<string, unknown>
/**
@@ -48,10 +53,18 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
parentPath,
parentSchemaPath,
req,
siblingData,
siblingDoc,
}: Args<T>): Promise<void> => {
const { path: fieldPath, schemaPath: fieldSchemaPath } = getFieldPaths({
field,
parentPath,
parentSchemaPath,
})
if (fieldAffectsData(field)) {
if (field.name === 'id') {
if (field.type === 'number' && typeof siblingData[field.name] === 'string') {
@@ -229,8 +242,11 @@ export const promise = async <T>({
operation,
originalDoc: doc,
overrideAccess,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingData[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingData[field.name],
})
@@ -288,7 +304,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: groupData,
siblingDoc: groupDoc,
})
@@ -301,7 +319,7 @@ export const promise = async <T>({
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row) => {
rows.forEach((row, i) => {
promises.push(
traverseFields({
id,
@@ -313,7 +331,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row,
siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]),
}),
@@ -329,7 +349,7 @@ export const promise = async <T>({
if (Array.isArray(rows)) {
const promises = []
rows.forEach((row) => {
rows.forEach((row, i) => {
const rowSiblingDoc = getExistingRowDoc(row, siblingDoc[field.name])
const blockTypeToMatch = row.blockType || rowSiblingDoc.blockType
const block = field.blocks.find((blockType) => blockType.slug === blockTypeToMatch)
@@ -348,7 +368,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: [...fieldPath, i],
req,
schemaPath: fieldSchemaPath,
siblingData: row,
siblingDoc: rowSiblingDoc,
}),
@@ -373,7 +395,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
})
@@ -405,7 +429,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData: tabSiblingData,
siblingDoc: tabSiblingDoc,
})
@@ -424,7 +450,9 @@ export const promise = async <T>({
global,
operation,
overrideAccess,
path: fieldPath,
req,
schemaPath: fieldSchemaPath,
siblingData,
siblingDoc,
})
@@ -432,6 +460,46 @@ export const promise = async <T>({
break
}
case 'richText': {
if (!field?.editor) {
throw new MissingEditorProp(field) // while we allow disabling editor functionality, you should not have any richText fields defined if you do not have an editor
}
if (typeof field?.editor === 'function') {
throw new Error('Attempted to access unsanitized rich text editor.')
}
const editor: RichTextAdapter = field?.editor
if (editor?.hooks?.beforeValidate?.length) {
await editor.hooks.beforeValidate.reduce(async (priorHook, currentHook) => {
await priorHook
const hookedValue = await currentHook({
collection,
context,
data,
field,
global,
operation,
originalDoc: doc,
overrideAccess,
path: fieldPath,
previousSiblingDoc: siblingDoc,
previousValue: siblingData[field.name],
req,
schemaPath: fieldSchemaPath,
siblingData,
value: siblingData[field.name],
})
if (hookedValue !== undefined) {
siblingData[field.name] = hookedValue
}
}, Promise.resolve())
}
break
}
default: {
break
}

View File

@@ -18,7 +18,9 @@ type Args<T> = {
id?: number | string
operation: 'create' | 'update'
overrideAccess: boolean
path: (number | string)[]
req: PayloadRequestWithData
schemaPath: string[]
siblingData: Record<string, unknown>
/**
* The original siblingData (not modified by any hooks)
@@ -36,7 +38,9 @@ export const traverseFields = async <T>({
global,
operation,
overrideAccess,
path,
req,
schemaPath,
siblingData,
siblingDoc,
}: Args<T>): Promise<void> => {
@@ -53,6 +57,8 @@ export const traverseFields = async <T>({
global,
operation,
overrideAccess,
parentPath: path,
parentSchemaPath: schemaPath,
req,
siblingData,
siblingDoc,

View File

@@ -7,6 +7,7 @@ import { fieldAffectsData } from '../../fields/config/types.js'
import mergeBaseFields from '../../fields/mergeBaseFields.js'
import { toWords } from '../../utilities/formatLabels.js'
import baseVersionFields from '../../versions/baseFields.js'
import { versionDefaults } from '../../versions/defaults.js'
export const sanitizeGlobals = async (
config: Config,
@@ -47,15 +48,20 @@ export const sanitizeGlobals = async (
if (global.versions.drafts === true) {
global.versions.drafts = {
autosave: false,
validate: false,
}
}
if (global.versions.drafts.autosave === true) {
global.versions.drafts.autosave = {
interval: 2000,
interval: versionDefaults.autosaveInterval,
}
}
if (global.versions.drafts.validate === undefined) {
global.versions.drafts.validate = false
}
global.fields = mergeBaseFields(global.fields, baseVersionFields)
}
}

View File

@@ -90,6 +90,7 @@ const globalSchema = joi
interval: joi.number(),
}),
),
validate: joi.boolean(),
}),
joi.boolean(),
),

View File

@@ -167,7 +167,8 @@ export const updateOperation = async <TSlug extends keyof GeneratedTypes['global
global: globalConfig,
operation: 'update',
req,
skipValidation: shouldSaveDraft,
skipValidation:
shouldSaveDraft && globalConfig.versions.drafts && !globalConfig.versions.drafts.validate,
})
// /////////////////////////////////////

View File

@@ -2,7 +2,10 @@ import type { ExecutionResult, GraphQLSchema, ValidationRule } from 'graphql'
import type { OperationArgs, Request as graphQLRequest } from 'graphql-http'
import type pino from 'pino'
import { spawn } from 'child_process'
import crypto from 'crypto'
import { fileURLToPath } from 'node:url'
import path from 'path'
import type { AuthArgs } from './auth/operations/auth.js'
import type { Result as ForgotPasswordResult } from './auth/operations/forgotPassword.js'
@@ -56,6 +59,9 @@ import flattenFields from './utilities/flattenTopLevelFields.js'
import Logger from './utilities/logger.js'
import { serverInit as serverInitTelemetry } from './utilities/telemetry/events/serverInit.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
/**
* @description Payload
*/
@@ -301,6 +307,31 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
[slug: string]: any // TODO: Type this
} = {}
async bin({
args,
cwd,
log,
}: {
args: string[]
cwd?: string
log?: boolean
}): Promise<{ code: number }> {
return new Promise((resolve, reject) => {
const spawned = spawn('node', [path.resolve(dirname, '../bin.js'), ...args], {
cwd,
stdio: log || log === undefined ? 'inherit' : 'ignore',
})
spawned.on('exit', (code) => {
resolve({ code })
})
spawned.on('error', (error) => {
reject(error)
})
})
}
/**
* @description delete one or more documents
* @param options
@@ -363,6 +394,16 @@ export class BasePayload<TGeneratedTypes extends GeneratedTypes> {
}
})
// Generate types on startup
if (process.env.NODE_ENV !== 'production' && this.config.typescript.autoGenerate !== false) {
// We cannot run it directly here, as generate-types imports json-schema-to-typescript, which breaks on turbopack.
// see: https://github.com/vercel/next.js/issues/66723
void this.bin({
args: ['generate:types'],
log: false,
})
}
this.db = this.config.db.init({ payload: this })
this.db.payload = this

View File

@@ -133,21 +133,23 @@ export const generateFileData = async <T>({
if (fileIsAnimated) sharpOptions.animated = true
if (fileHasAdjustments && sharp) {
if (sharp && (fileIsAnimated || fileHasAdjustments)) {
if (file.tempFilePath) {
sharpFile = sharp(file.tempFilePath, sharpOptions).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081
} else {
sharpFile = sharp(file.data, sharpOptions).rotate() // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081
}
if (resizeOptions) {
sharpFile = sharpFile.resize(resizeOptions)
}
if (formatOptions) {
sharpFile = sharpFile.toFormat(formatOptions.format, formatOptions.options)
}
if (trimOptions) {
sharpFile = sharpFile.trim(trimOptions)
if (fileHasAdjustments) {
if (resizeOptions) {
sharpFile = sharpFile.resize(resizeOptions)
}
if (formatOptions) {
sharpFile = sharpFile.toFormat(formatOptions.format, formatOptions.options)
}
if (trimOptions) {
sharpFile = sharpFile.trim(trimOptions)
}
}
}
@@ -201,7 +203,6 @@ export const generateFileData = async <T>({
let fileForResize = file
if (cropData && sharp) {
const metadata = await sharpFile.metadata()
const { data: croppedImage, info } = await cropImage({ cropData, dimensions, file, sharp })
filesToSave.push({
@@ -215,7 +216,11 @@ export const generateFileData = async <T>({
size: info.size,
}
fileData.width = info.width
fileData.height = fileIsAnimated ? info.height / metadata.pages : info.height
fileData.height = info.height
if (fileIsAnimated) {
const metadata = await sharpFile.metadata()
fileData.height = metadata.pages ? info.height / metadata.pages : info.height
}
fileData.filesize = info.size
if (file.tempFilePath) {

View File

@@ -364,7 +364,7 @@ export async function resizeAndTransformImageSizes({
name: imageResizeConfig.name,
filename: imageNameWithDimensions,
filesize: size,
height: fileIsAnimated ? height / metadata.pages : height,
height: fileIsAnimated && metadata.pages ? height / metadata.pages : height,
mimeType: mimeInfo?.mime || mimeType,
sizesToSave: [{ buffer: bufferData, path: imagePath }],
width,

View File

@@ -0,0 +1,3 @@
export const versionDefaults = {
autosaveInterval: 2000,
}

View File

@@ -4,10 +4,12 @@ export type Autosave = {
export type IncomingDrafts = {
autosave?: Autosave | boolean
validate?: boolean
}
export type SanitizedDrafts = {
autosave: Autosave | false
validate: boolean
}
export type IncomingCollectionVersions = {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "The official cloud storage plugin for Payload CMS",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "The official Nested Docs plugin for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,34 +0,0 @@
// @ts-nocheck
/**
* Simple object check.
* @param item
* @returns {boolean}
*/
export function isObject(item: unknown): boolean {
return item && typeof item === 'object' && !Array.isArray(item)
}
/**
* Deep merge two objects.
* @param target
* @param ...sources
*/
export default function deepMerge<T, R>(target: T, source: R): T {
const output = { ...target }
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach((key) => {
if (isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] })
} else {
output[key] = deepMerge(target[key], source[key])
}
} else {
Object.assign(output, { [key]: source[key] })
}
})
}
return output
}

View File

@@ -1,80 +1,85 @@
import type { Config } from 'payload/config'
import type { CollectionConfig, Field } from 'payload/types'
import type { RedirectsPluginConfig } from './types.js'
import deepMerge from './deepMerge.js'
export const redirectsPlugin =
(pluginConfig: RedirectsPluginConfig) =>
(incomingConfig: Config): Config => ({
...incomingConfig,
collections: [
...(incomingConfig?.collections || []),
deepMerge(
{
slug: 'redirects',
access: {
read: (): boolean => true,
},
admin: {
defaultColumns: ['from', 'to.type', 'createdAt'],
},
fields: [
{
name: 'from',
type: 'text',
index: true,
label: 'From URL',
required: true,
(incomingConfig: Config): Config => {
const defaultFields: Field[] = [
{
name: 'from',
type: 'text',
index: true,
label: 'From URL',
required: true,
},
{
name: 'to',
type: 'group',
fields: [
{
name: 'type',
type: 'radio',
admin: {
layout: 'horizontal',
},
{
name: 'to',
type: 'group',
fields: [
{
name: 'type',
type: 'radio',
admin: {
layout: 'horizontal',
},
defaultValue: 'reference',
label: 'To URL Type',
options: [
{
label: 'Internal link',
value: 'reference',
},
{
label: 'Custom URL',
value: 'custom',
},
],
},
{
name: 'reference',
type: 'relationship',
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
label: 'Document to redirect to',
relationTo: pluginConfig?.collections || [],
required: true,
},
{
name: 'url',
type: 'text',
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
label: 'Custom URL',
required: true,
},
],
label: false,
defaultValue: 'reference',
label: 'To URL Type',
options: [
{
label: 'Internal link',
value: 'reference',
},
{
label: 'Custom URL',
value: 'custom',
},
],
},
{
name: 'reference',
type: 'relationship',
admin: {
condition: (_, siblingData) => siblingData?.type === 'reference',
},
],
},
pluginConfig?.overrides || {},
),
],
})
label: 'Document to redirect to',
relationTo: pluginConfig?.collections || [],
required: true,
},
{
name: 'url',
type: 'text',
admin: {
condition: (_, siblingData) => siblingData?.type === 'custom',
},
label: 'Custom URL',
required: true,
},
],
label: false,
},
]
const redirectsCollection: CollectionConfig = {
...(pluginConfig?.overrides || {}),
slug: pluginConfig?.overrides?.slug || 'redirects',
access: {
read: () => true,
...(pluginConfig?.overrides?.access || {}),
},
admin: {
defaultColumns: ['from', 'to.type', 'createdAt'],
...(pluginConfig?.overrides?.admin || {}),
},
fields:
pluginConfig?.overrides?.fields && typeof pluginConfig?.overrides?.fields === 'function'
? pluginConfig?.overrides.fields({ defaultFields })
: defaultFields,
}
return {
...incomingConfig,
collections: [...(incomingConfig?.collections || []), redirectsCollection],
}
}

View File

@@ -1,6 +1,8 @@
import type { CollectionConfig } from 'payload/types'
import type { CollectionConfig, Field } from 'payload/types'
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
export type RedirectsPluginConfig = {
collections?: string[]
overrides?: Partial<CollectionConfig>
overrides?: Partial<Omit<CollectionConfig, 'fields'>> & { fields: FieldsOverride }
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-relationship-object-ids",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "A Payload plugin to store all relationship IDs as ObjectIDs",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "Search plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "SEO plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.0.0-beta.42",
"version": "3.0.0-beta.46",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -41,8 +41,8 @@
"translateNewKeys": "tsx scripts/translateNewKeys.ts"
},
"dependencies": {
"@faceless-ui/modal": "2.1.0-rc.0",
"@faceless-ui/scroll-info": "1.4.0-rc.0",
"@faceless-ui/modal": "3.0.0-beta.0",
"@faceless-ui/scroll-info": "2.0.0-beta.0",
"@lexical/headless": "0.16.0",
"@lexical/link": "0.16.0",
"@lexical/list": "0.16.0",
@@ -75,8 +75,8 @@
"payload": "workspace:*"
},
"peerDependencies": {
"@faceless-ui/modal": "2.1.0-rc.0",
"@faceless-ui/scroll-info": "1.4.0-rc.0",
"@faceless-ui/modal": "3.0.0-beta.0",
"@faceless-ui/scroll-info": "2.0.0-beta.0",
"@lexical/headless": "0.16.0",
"@lexical/link": "0.16.0",
"@lexical/list": "0.16.0",

View File

@@ -51,7 +51,6 @@ export const BlockContent: React.FC<Props> = (props) => {
formData,
formSchema,
nodeKey,
path,
reducedBlock: { labels },
schemaPath,
} = props
@@ -111,17 +110,21 @@ export const BlockContent: React.FC<Props> = (props) => {
// does not have, even if it's undefined.
// Currently, this happens if a block has another sub-blocks field. Inside formData, that sub-blocks field has an undefined blockName property.
// Inside of fields.data however, that sub-blocks blockName property does not exist at all.
function removeUndefinedAndNullRecursively(obj: object) {
Object.keys(obj).forEach((key) => {
if (obj[key] && typeof obj[key] === 'object') {
removeUndefinedAndNullRecursively(obj[key])
} else if (obj[key] === undefined || obj[key] === null) {
function removeUndefinedAndNullAndEmptyArraysRecursively(obj: object) {
for (const key in obj) {
const value = obj[key]
if (Array.isArray(value) && !value?.length) {
delete obj[key]
} else if (value && typeof value === 'object') {
removeUndefinedAndNullAndEmptyArraysRecursively(value)
} else if (value === undefined || value === null) {
delete obj[key]
}
})
}
}
removeUndefinedAndNullRecursively(newFormData)
removeUndefinedAndNullRecursively(formData)
removeUndefinedAndNullAndEmptyArraysRecursively(newFormData)
removeUndefinedAndNullAndEmptyArraysRecursively(formData)
// Only update if the data has actually changed. Otherwise, we may be triggering an unnecessary value change,
// which would trigger the "Leave without saving" dialog unnecessarily

View File

@@ -29,10 +29,8 @@ import type { BlocksFeatureClientProps } from '../feature.client.js'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider.js'
import { BlockContent } from './BlockContent.js'
import './index.scss'
import { removeEmptyArrayValues } from './removeEmptyArrayValues.js'
type Props = {
blockFieldWrapperName: string
children?: React.ReactNode
formData: BlockFields
@@ -44,7 +42,7 @@ type Props = {
}
export const BlockComponent: React.FC<Props> = (props) => {
const { blockFieldWrapperName, formData, nodeKey } = props
const { formData, nodeKey } = props
const config = useConfig()
const submitted = useFormSubmitted()
const { id } = useDocumentInfo()
@@ -81,7 +79,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
if (state) {
setInitialState({
...removeEmptyArrayValues({ fields: state }),
...state,
blockName: {
initialValue: '',
passesCondition: true,
@@ -175,6 +173,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
</Collapsible>
)
}, [
classNames,
fieldMap,
parentLexicalRichTextField,
nodeKey,
@@ -182,7 +181,6 @@ export const BlockComponent: React.FC<Props> = (props) => {
submitted,
initialState,
reducedBlock,
blockFieldWrapperName,
onChange,
schemaFieldsPath,
path,

View File

@@ -1,7 +1,7 @@
'use client'
import type { LexicalCommand, LexicalEditor } from 'lexical'
import * as facelessUIImport from '@faceless-ui/modal'
import { useModal } from '@faceless-ui/modal'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { formatDrawerSlug } from '@payloadcms/ui/elements/Drawer'
import { BlocksDrawer } from '@payloadcms/ui/fields/Blocks/BlocksDrawer'
@@ -54,8 +54,6 @@ const insertBlock = ({
}
export const BlocksDrawerComponent: React.FC = () => {
const { useModal } = facelessUIImport
const [editor] = useLexicalComposerContext()
const { editorConfig, uuid } = useEditorConfigContext()

View File

@@ -10,9 +10,9 @@ import type { BlocksFeatureClientProps } from './feature.client.js'
import { createNode } from '../typeUtilities.js'
import { BlocksFeatureClientComponent } from './feature.client.js'
import { blockPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js'
import { BlockNode } from './nodes/BlocksNode.js'
import { blockPopulationPromiseHOC } from './populationPromise.js'
import { blockValidationHOC } from './validate.js'
export type BlocksFeatureProps = {
@@ -114,71 +114,17 @@ export const BlocksFeature: FeatureProviderProviderServer<
i18n,
nodes: [
createNode({
/* // TODO: Implement these hooks once docWithLocales / originalSiblingDoc => node matching has been figured out
hooks: {
beforeChange: [
async ({ context, findMany, node, operation, overrideAccess, req }) => {
const blockType = node.fields.blockType
getSubFields: ({ node, req }) => {
const blockType = node.fields.blockType
const block = deepCopyObject(
props.blocks.find((block) => block.slug === blockType),
)
await beforeChangeTraverseFields({
id: null,
collection: null,
context,
data: node.fields,
doc: node.fields,
fields: sanitizedBlock.fields,
global: null,
mergeLocaleActions: [],
operation:
operation === 'create' || operation === 'update' ? operation : 'update',
overrideAccess,
path: '',
req,
siblingData: node.fields,
siblingDoc: node.fields,
})
return node
},
],
beforeValidate: [
async ({ context, findMany, node, operation, overrideAccess, req }) => {
const blockType = node.fields.blockType
const block = deepCopyObject(
props.blocks.find((block) => block.slug === blockType),
)
await beforeValidateTraverseFields({
id: null,
collection: null,
context,
data: node.fields,
doc: node.fields,
fields: sanitizedBlock.fields,
global: null,
operation:
operation === 'create' || operation === 'update' ? operation : 'update',
overrideAccess,
req,
siblingData: node.fields,
siblingDoc: node.fields,
})
return node
},
],
},*/
const block = props.blocks.find((block) => block.slug === blockType)
return block?.fields
},
getSubFieldsData: ({ node }) => {
return node?.fields
},
graphQLPopulationPromises: [blockPopulationPromiseHOC(props)],
node: BlockNode,
populationPromises: [blockPopulationPromiseHOC(props)],
validations: [blockValidationHOC(props)],
}),
],

View File

@@ -2,7 +2,7 @@ import type { PopulationPromise } from '../types.js'
import type { BlocksFeatureProps } from './feature.server.js'
import type { SerializedBlockNode } from './nodes/BlocksNode.js'
import { recurseNestedFields } from '../../../populate/recurseNestedFields.js'
import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js'
export const blockPopulationPromiseHOC = (
props: BlocksFeatureProps,
@@ -21,7 +21,6 @@ export const blockPopulationPromiseHOC = (
populationPromises,
req,
showHiddenFields,
siblingDoc,
}) => {
const blockFieldData = node.fields
@@ -31,22 +30,21 @@ export const blockPopulationPromiseHOC = (
return
}
recurseNestedFields({
recursivelyPopulateFieldsForGraphQL({
context,
currentDepth,
data: blockFieldData,
depth,
draft,
editorPopulationPromises,
fieldPromises,
fields: block.fields,
findMany,
flattenLocales: false, // Disable localization handling which does not work properly yet. Once we fully support hooks, this can be enabled (pass through flattenLocales again)
flattenLocales,
overrideAccess,
populationPromises,
req,
showHiddenFields,
// The afterReadPromise gets its data from looking for field.name inside the siblingDoc. Thus, here we cannot pass the whole document's siblingDoc, but only the siblingDoc (sibling fields) of the current field.
draft,
siblingDoc: blockFieldData,
})
}

View File

@@ -1,6 +1,6 @@
import type { User } from 'payload/auth'
import type { SanitizedConfig } from 'payload/config'
import type { Field, RadioField, TextField } from 'payload/types'
import type { FieldAffectingData, RadioField, TextField } from 'payload/types'
import { validateUrl } from '../../../lexical/utils/url.js'
@@ -9,7 +9,7 @@ export const getBaseFields = (
enabledCollections: false | string[],
disabledCollections: false | string[],
maxDepth?: number,
): Field[] => {
): FieldAffectingData[] => {
let enabledRelations: string[]
/**
@@ -33,7 +33,7 @@ export const getBaseFields = (
.map(({ slug }) => slug)
}
const baseFields: Field[] = [
const baseFields: FieldAffectingData[] = [
{
name: 'text',
type: 'text',

View File

@@ -1,5 +1,5 @@
import type { Config, SanitizedConfig } from 'payload/config'
import type { Field } from 'payload/types'
import type { Field, FieldAffectingData } from 'payload/types'
import { traverseFields } from '@payloadcms/ui/utilities/buildFieldSchemaMap/traverseFields'
import { sanitizeFields } from 'payload/config'
@@ -11,12 +11,12 @@ import type { ClientProps } from './feature.client.js'
import { convertLexicalNodesToHTML } from '../converters/html/converter/index.js'
import { createNode } from '../typeUtilities.js'
import { LinkFeatureClientComponent } from './feature.client.js'
import { linkPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js'
import { LinkMarkdownTransformer } from './markdownTransformer.js'
import { AutoLinkNode } from './nodes/AutoLinkNode.js'
import { LinkNode } from './nodes/LinkNode.js'
import { transformExtraFields } from './plugins/floatingLinkEditor/utilities.js'
import { linkPopulationPromiseHOC } from './populationPromise.js'
import { linkValidation } from './validate.js'
export type ExclusiveLinkCollectionsProps =
@@ -46,7 +46,12 @@ export type LinkFeatureServerProps = ExclusiveLinkCollectionsProps & {
* A function or array defining additional fields for the link feature. These will be
* displayed in the link editor drawer.
*/
fields?: ((args: { config: SanitizedConfig; defaultFields: Field[] }) => Field[]) | Field[]
fields?:
| ((args: {
config: SanitizedConfig
defaultFields: FieldAffectingData[]
}) => (Field | FieldAffectingData)[])
| Field[]
/**
* Sets a maximum population depth for the internal doc default field of link, regardless of the remaining depth when the field is reached.
* This behaves exactly like the maxDepth properties of relationship and upload fields.
@@ -82,6 +87,13 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
})
props.fields = sanitizedFields
// the text field is not included in the node data.
// Thus, for tasks like validation, we do not want to pass it a text field in the schema which will never have data.
// Otherwise, it will cause a validation error (field is required).
const sanitizedFieldsWithoutText = deepCopyObject(sanitizedFields).filter(
(field) => field.name !== 'text',
)
return {
ClientComponent: LinkFeatureClientComponent,
clientFeatureProps: {
@@ -143,16 +155,9 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
nodeTypes: [AutoLinkNode.getType()],
},
},
hooks: {
afterRead: [
({ node }) => {
return node
},
],
},
node: AutoLinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
validations: [linkValidation(props)],
// Since AutoLinkNodes are just internal links, they need no hooks or graphQL population promises
validations: [linkValidation(props, sanitizedFieldsWithoutText)],
}),
createNode({
converters: {
@@ -181,9 +186,15 @@ export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps,
nodeTypes: [LinkNode.getType()],
},
},
getSubFields: ({ node, req }) => {
return sanitizedFieldsWithoutText
},
getSubFieldsData: ({ node }) => {
return node?.fields
},
graphQLPopulationPromises: [linkPopulationPromiseHOC(props)],
node: LinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
validations: [linkValidation(props)],
validations: [linkValidation(props, sanitizedFieldsWithoutText)],
}),
],
serverFeatureProps: props,

View File

@@ -2,7 +2,7 @@ import type { PopulationPromise } from '../types.js'
import type { LinkFeatureServerProps } from './feature.server.js'
import type { SerializedLinkNode } from './nodes/types.js'
import { recurseNestedFields } from '../../../populate/recurseNestedFields.js'
import { recursivelyPopulateFieldsForGraphQL } from '../../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js'
export const linkPopulationPromiseHOC = (
props: LinkFeatureServerProps,
@@ -30,7 +30,7 @@ export const linkPopulationPromiseHOC = (
* Should populate all fields, including the doc field (for internal links), as it's treated like a normal field
*/
if (Array.isArray(props.fields)) {
recurseNestedFields({
recursivelyPopulateFieldsForGraphQL({
context,
currentDepth,
data: node.fields,
@@ -40,7 +40,7 @@ export const linkPopulationPromiseHOC = (
fieldPromises,
fields: props.fields,
findMany,
flattenLocales: false, // Disable localization handling which does not work properly yet. Once we fully support hooks, this can be enabled (pass through flattenLocales again)
flattenLocales,
overrideAccess,
populationPromises,
req,

View File

@@ -11,7 +11,7 @@ import { LinkNode } from './LinkNode.js'
export class AutoLinkNode extends LinkNode {
static clone(node: AutoLinkNode): AutoLinkNode {
return new AutoLinkNode({ fields: node.__fields, key: node.__key })
return new AutoLinkNode({ id: undefined, fields: node.__fields, key: node.__key })
}
static getType(): string {
@@ -61,7 +61,7 @@ export class AutoLinkNode extends LinkNode {
}
export function $createAutoLinkNode({ fields }: { fields: LinkFields }): AutoLinkNode {
return $applyNodeReplacement(new AutoLinkNode({ fields }))
return $applyNodeReplacement(new AutoLinkNode({ id: undefined, fields }))
}
export function $isAutoLinkNode(node: LexicalNode | null | undefined): node is AutoLinkNode {
return node instanceof AutoLinkNode

View File

@@ -11,6 +11,7 @@ import type {
} from 'lexical'
import { addClassNamesToElement, isHTMLAnchorElement } from '@lexical/utils'
import ObjectID from 'bson-objectid'
import {
$applyNodeReplacement,
$createTextNode,
@@ -29,8 +30,10 @@ const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', '
/** @noInheritDoc */
export class LinkNode extends ElementNode {
__fields: LinkFields
__id: string
constructor({
id,
fields = {
doc: null,
linkType: 'custom',
@@ -40,14 +43,17 @@ export class LinkNode extends ElementNode {
key,
}: {
fields: LinkFields
id: string
key?: NodeKey
}) {
super(key)
this.__fields = fields
this.__id = id
}
static clone(node: LinkNode): LinkNode {
return new LinkNode({
id: node.__id,
fields: node.__fields,
key: node.__key,
})
@@ -76,7 +82,13 @@ export class LinkNode extends ElementNode {
serializedNode.version = 2
}
if (serializedNode.version === 2 && !serializedNode.id) {
serializedNode.id = new ObjectID.default().toHexString()
serializedNode.version = 3
}
const node = $createLinkNode({
id: serializedNode.id,
fields: serializedNode.fields,
})
node.setFormat(serializedNode.format)
@@ -115,12 +127,17 @@ export class LinkNode extends ElementNode {
}
exportJSON(): SerializedLinkNode {
return {
const returnObject: SerializedLinkNode = {
...super.exportJSON(),
type: this.getType(),
fields: this.getFields(),
version: 2,
version: 3,
}
const id = this.getID()
if (id) {
returnObject.id = id
}
return returnObject
}
extractWithChild(
@@ -146,6 +163,10 @@ export class LinkNode extends ElementNode {
return this.getLatest().__fields
}
getID(): string {
return this.getLatest().__id
}
insertNewAfter(selection: RangeSelection, restoreSelection = true): ElementNodeType | null {
const element = this.getParentOrThrow().insertNewAfter(selection, restoreSelection)
if ($isElementNode(element)) {
@@ -216,6 +237,7 @@ function $convertAnchorElement(domNode: Node): DOMConversionOutput {
const content = domNode.textContent
if (content !== null && content !== '') {
node = $createLinkNode({
id: new ObjectID.default().toHexString(),
fields: {
doc: null,
linkType: 'custom',
@@ -228,8 +250,13 @@ function $convertAnchorElement(domNode: Node): DOMConversionOutput {
return { node }
}
export function $createLinkNode({ fields }: { fields: LinkFields }): LinkNode {
return $applyNodeReplacement(new LinkNode({ fields }))
export function $createLinkNode({ id, fields }: { fields: LinkFields; id?: string }): LinkNode {
return $applyNodeReplacement(
new LinkNode({
id: id ?? new ObjectID.default().toHexString(),
fields,
}),
)
}
export function $isLinkNode(node: LexicalNode | null | undefined): node is LinkNode {
@@ -349,8 +376,6 @@ export function $toggleLink(payload: LinkPayload): void {
})
}
}
/** @deprecated renamed to {@link $toggleLink} by @lexical/eslint-plugin rules-of-lexical */
export const toggleLink = $toggleLink
function $getLinkAncestor(node: LexicalNode): LinkNode | null {
return $getAncestor(node, (ancestor) => $isLinkNode(ancestor)) as LinkNode

View File

@@ -21,7 +21,8 @@ export type LinkFields = {
export type SerializedLinkNode = Spread<
{
fields: LinkFields
id?: string // optional if AutoLinkNode
},
SerializedElementNode
>
export type SerializedAutoLinkNode = SerializedLinkNode
export type SerializedAutoLinkNode = Omit<SerializedLinkNode, 'id'>

View File

@@ -3,7 +3,7 @@ import type { LexicalNode } from 'lexical'
import type { FormState } from 'payload/types'
import type { Data } from 'payload/types'
import * as facelessUIImport from '@faceless-ui/modal'
import { useModal } from '@faceless-ui/modal'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import { getTranslation } from '@payloadcms/translations'
@@ -36,8 +36,6 @@ import { $isLinkNode, TOGGLE_LINK_COMMAND } from '../../../nodes/LinkNode.js'
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './commands.js'
export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.ReactNode {
const { useModal } = facelessUIImport
const [editor] = useLexicalComposerContext()
const editorRef = useRef<HTMLDivElement | null>(null)
@@ -50,7 +48,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
const { i18n, t } = useTranslation()
const [stateData, setStateData] = useState<{} | (LinkFields & { text: string })>({})
const [stateData, setStateData] = useState<{} | (LinkFields & { id?: string; text: string })>({})
const { closeModal, isModalOpen, toggleModal } = useModal()
const editDepth = useEditDepth()
@@ -116,6 +114,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
newTab: undefined,
url: '',
...focusLinkParent.getFields(),
id: focusLinkParent.getID(),
text: focusLinkParent.getTextContent(),
}

View File

@@ -1,5 +1,5 @@
import type { SanitizedConfig } from 'payload/config'
import type { Field } from 'payload/types'
import type { Field, FieldAffectingData } from 'payload/types'
import { getBaseFields } from '../../drawer/baseFields.js'
@@ -8,14 +8,14 @@ import { getBaseFields } from '../../drawer/baseFields.js'
*/
export function transformExtraFields(
customFieldSchema:
| ((args: { config: SanitizedConfig; defaultFields: Field[] }) => Field[])
| ((args: { config: SanitizedConfig; defaultFields: FieldAffectingData[] }) => Field[])
| Field[],
config: SanitizedConfig,
enabledCollections?: false | string[],
disabledCollections?: false | string[],
maxDepth?: number,
): Field[] {
const baseFields: Field[] = getBaseFields(
const baseFields: FieldAffectingData[] = getBaseFields(
config,
enabledCollections,
disabledCollections,
@@ -29,7 +29,7 @@ export function transformExtraFields(
} else if (Array.isArray(customFieldSchema)) {
fields = customFieldSchema
} else {
fields = baseFields
fields = baseFields as Field[]
}
return fields

View File

@@ -16,7 +16,7 @@ import type { LinkFields } from '../../nodes/types.js'
import type { LinkPayload } from '../floatingLinkEditor/types.js'
import { validateUrl } from '../../../../lexical/utils/url.js'
import { LinkNode, TOGGLE_LINK_COMMAND, toggleLink } from '../../nodes/LinkNode.js'
import { $toggleLink, LinkNode, TOGGLE_LINK_COMMAND } from '../../nodes/LinkNode.js'
export const LinkPlugin: PluginComponent<ClientProps> = () => {
const [editor] = useLexicalComposerContext()
@@ -29,7 +29,7 @@ export const LinkPlugin: PluginComponent<ClientProps> = () => {
editor.registerCommand(
TOGGLE_LINK_COMMAND,
(payload: LinkPayload) => {
toggleLink(payload)
$toggleLink(payload)
return true
},
COMMAND_PRIORITY_LOW,

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