Compare commits

...

33 Commits

Author SHA1 Message Date
Dan Ribbens
275f9aa026 chore: consistent naming for listControlsMenu 2025-02-06 18:39:43 -05:00
Jessica Chowdhury
7277f17f14 feat(ui): adds admin.components.listControlsMenu option (#10981)
### What?
Adds new option `admin.components.listControlsMenu` to allow custom
components to be injected after the existing list controls in the
collection list view.

### Why?
Needed to facilitate import/export plugin.

#### Preview & Testing

Use `pnpm dev admin` to see example component and see test added to
`test/admin/e2e/list-view`.
<img width="1443" alt="Screenshot 2025-02-04 at 4 59 33 PM"
src="https://github.com/user-attachments/assets/dffe3a4b-5370-4004-86e6-23dabccdac52"
/>

---------

Co-authored-by: Dan Ribbens <DanRibbens@users.noreply.github.com>
2025-02-06 18:24:04 -05:00
Jacob Fletcher
7a73265bd6 fix(ui): clearing value from relationship filter leaves stale query (#11023)
When filtering the list view using conditions on a relationship field,
clearing the value from the field would leave it in the query despite
being removed from the component.
2025-02-06 17:44:32 -05:00
Jarrod Flesch
ec593b453e chore(plugin-multi-tenant): add better defaults for imported components (#11030)
Creates a default variables file to use in exported components.
Extension of https://github.com/payloadcms/payload/pull/10975.
2025-02-06 22:21:49 +00:00
Jarrod Flesch
a63a3d0518 feat(ui): adds filtering config option and implementation for filtering a… (#11007)
Adds the ability to filter what locales should be available per request.

This means that you can determine what locales are visible in the
localizer selection menu at the top of the admin panel. You could do
this per user, or implement a function that scopes these to tenants and
more.

Here is an example function that would scope certain locales to tenants:

**`payload.config.ts`**
```ts
// ... rest of payload config

localization: {
  defaultLocale: 'en',
  locales: ['en', 'es'],
  filterAvailableLocales: async ({ req, locales }) => {
    if (getTenantFromCookie(req.headers, 'text')) {
      try {
        const fullTenant = await req.payload.findByID({
          id: getTenantFromCookie(req.headers, 'text') as string,
          collection: 'tenants',
        })
        if (fullTenant && fullTenant.supportedLocales?.length) {
          return locales.filter((locale) => {
            return fullTenant.supportedLocales?.includes(locale.code as 'en' | 'es')
          })
        }
      } catch (_) {
        // do nothing
      }
    }
    return locales
  },
}
  ```

The filter above assumes you have a field on your tenants collection like so:

```ts
{
  name: 'supportedLocales',
  type: 'select',
  hasMany: true,
  options: [
    {
      label: 'English',
      value: 'en',
    },
    {
      label: 'Spanish',
      value: 'es',
    },
  ],
}
```
2025-02-06 16:57:59 -05:00
Sasha
57143b37d0 fix(db-postgres): ensure globals have createdAt, updatedAt and globalType fields (#10938)
Previously, data for globals was inconsistent across database adapters.
In Postgres, globals didn't store correct `createdAt`, `updatedAt`
fields and the `updateGlobal` lacked the `globalType` field. This PR
solves that without introducing schema changes.
2025-02-06 23:48:59 +02:00
Sasha
3ad56cd86f fix(db-postgres): select hasMany: true with autosave doesn't work properly (#11012)
Previously, select fields with `hasMany: true` didn't save properly in
Postgres on autosave.
2025-02-06 23:47:53 +02:00
Jacob Fletcher
05e6f3326b test: addListFilter helper (#11026)
Adds a new `addListFilter` e2e helper. This will help to standardize
this common functionality across all tests that require filtering list
tables and help reduce the overall lines of code within each test file.
2025-02-06 16:17:27 -05:00
Alessio Gravili
8b6ba625b8 refactor: do not use description functions for generated types JSDocs (#11027)
In https://github.com/payloadcms/payload/pull/9917 we automatically added `admin.description` as JSDocs to our generated types.

If a function was passed as a description, this could have created unnecessary noise in the generated types, as the output of the description function may differ depending on where and when it's executed.

Example:

```ts
description: () => {
  return `Current date: ${new Date().toString()}`
}
```

This PR disabled evaluating description functions for JSDocs generation
2025-02-06 21:16:44 +00:00
Alessio Gravili
2b76a0484c fix(richtext-lexical): duplicative error paths in validation (#11025) 2025-02-06 21:00:25 +00:00
Alessio Gravili
66318697dd chore: fix lexical tests that are failing on main branch (#11024) 2025-02-06 20:28:30 +00:00
Jacob Fletcher
8940726601 fix(ui): relationship filter clearing on blur (#11021)
When using the filter controls in the list view on a relationship field,
the select options would clear after clicking outside of the component
then never repopulate. This caused the component to remain in an
unusable state, where no options would appear unless the filter is
completely removed and re-added. The reason for this is that the
`react-select` component fires an `onInputChange` event on blur, and the
handler that is subscribed to this event was unknowingly clearing the
options.

This PR also renames the various filter components, i.e.
`RelationshipField` -> `RelationshipFilter`. This improves semantics and
dedupes their names from the actual field components.

This bug was first introduced in this PR: #10553
2025-02-06 15:27:34 -05:00
Alessio Gravili
ae32c555ac fix(richtext-lexical): ensure sub-fields have access to full document data in form state (#9869)
Fixes https://github.com/payloadcms/payload/issues/10940

This PR does the following:
- adds a `useDocumentForm` hook to access the document Form. Useful if
you are within a sub-Form
- ensure the `data` property passed to field conditions, read access
control, validation and filterOptions is always the top-level document
data. Previously, for fields within lexical blocks/links/upload, this
incorrectly was the lexical block-level data.
- adds a `blockData` property to hooks, field conditions,
read/update/create field access control, validation and filterOptions
for all fields. This allows you to access the data of the nearest parent
block, which is especially useful for lexical sub-fields. Users that
were previously depending on the incorrect behavior of the `data`
property in order to access the data of the lexical block can now switch
to the new `blockData` property
2025-02-06 13:49:17 -05:00
Alessio Gravili
8ed410456c fix(ui): improve useIgnoredEffect hook (#10961)
The `useIgnoredEffect` hook is useful in firing an effect only when a _subset_ of dependencies change, despite subscribing to many dependencies. But the previous implementation of `useIgnoredEffect` had a few problems:

- The effect did not receive the updated values of `ignoredDeps` - thus, `useIgnoredEffect` pretty much worked the same way as using `useEffect` and omitting said dependencies from the dependency array. This caused the `ignoredDeps` values to be stale.
- It compared objects by value instead of reference, which is slower and behaves differently than `useEffect` itself.
- Edge cases where the effect does not run even though the dependencies have changed. E.g. if an `ignoredDep` has value `null` and a `dep` changes its value from _something_ to `null`, the effect incorrectly does **not** run, as the current logic detects that said value is part of `ignoredDeps` => no `dep` actually changed.

This PR replaces the `useIgnoredEffect` hook with a new pattern which to combine `useEffect` with a new `useEffectEvent` hook as described here: https://react.dev/learn/separating-events-from-effects#extracting-non-reactive-logic-out-of-effects. While this is not available in React 19 stable, there is a polyfill available that's already used in several big projects (e.g. react-spectrum and bluesky).
2025-02-06 11:37:49 -07:00
Germán Jabloñski
824f9a7f4d chore(cpa): add ts strict mode (#10914) 2025-02-06 12:02:38 -05:00
Jarrod Flesch
f25acb801c fix(plugin-multi-tenant): correctly set doc default value on load (#11018)
When navigating from the list view, with no tenant selected, the
document would load and set the hidden tenant field to the first tenant
option.

This was caused by incorrect logic inside the TenantField useEffect that
sets the value on the field upon load.
2025-02-06 16:24:06 +00:00
Germán Jabloñski
5f58daffd0 chore(richtext-lexical): fix unchecked indexed access (part 3) (#11014)
I start to list the PRs because there may be a few.

1. https://github.com/payloadcms/payload/pull/10982
2. https://github.com/payloadcms/payload/pull/11013
2025-02-06 15:44:02 +00:00
Germán Jabloñski
e413e1df1c chore(richtext-lexical): fix unchecked indexed acess in lexical blocks feature (#11013)
This PR is part of the process of fixing `noUncheckedIndexedAccess` in
richtext-lexical.
2025-02-06 14:07:41 +00:00
Dan Ribbens
bdbb99972c fix(ui): allow schedule publish to be accessed without changes (#10999)
### What?
Using the versions drafts feature and scheduling publish jobs, the UI
does not allow you to open the schedule publish drawer when the document
has been published already.

### Why?
Because of this you cannot schedule unpublish, unless as a user you
modify a form field as a workaround before clicking the publish submenu.

### How?
This change extends the Button props to include subMenuDisableOverride
allowing the schedule publish submenu to still be used on even when the
form is not modified.

Before: 

![image](https://github.com/user-attachments/assets/a69f2e39-d74e-476c-9744-2b8523e2b831)


With changes:

![Animation](https://github.com/user-attachments/assets/0a13fe33-974c-402b-8464-6ef2cb397d86)
2025-02-06 06:58:43 -05:00
Simon Vreman
e29ac523d3 fix(ui): apply cacheTags upload config property to other admin panel image components (#10801)
In https://github.com/payloadcms/payload/pull/10319, the `cacheTags`
property was added to the image config. This achieves the goal as
described, however, there are still other places where this issue
occurs, which should be handled in the same way. This PR aims to apply
it to those instances.
2025-02-06 06:04:03 -05:00
Tobias Odendahl
d8cfdc7bcb feat(ui): improve hasMany TextField UX (#10976)
### What?

This updates the UX of `TextFields` with `hasMany: true` by:
- Removing the dropdown menu and its indicator
- Removing the ClearIndicator
- Making text items directly editable

### Why?
- The dropdown didn’t enhance usability.
- The ClearIndicator removed all values at once with no way to undo,
risking accidental data loss. Backspace still allows quick and
intentional clearing.
- Previously, text items could only be removed and re-added, but not
edited inline. Allowing inline editing improves the editing experience.

### How?


https://github.com/user-attachments/assets/02e8cc26-7faf-4444-baa1-39ce2b4547fa
2025-02-06 06:02:55 -05:00
Jacob Fletcher
694c76d51a test: cleans up fields-relationship test suite (#11003)
The `fields-relationship` test suite is disorganized to the point of
being unusable. This makes it very difficult to digest at a high level
and add new tests.

This PR cleans it up in the following ways:

- Moves collection configs to their own standalone files
- Moves the seed function to its own file
- Consolidates collection slugs in their own file
- Uses generated types instead of defining them statically
- Wraps the `filterOptions` e2e tests within a describe block

Related, there are three distinct test suites where we manage
relationships: `relationships`, `fields-relationship`, and `fields >
relationships`. In the future we ought to consolidate at least two of
these. IMO the `fields > relationship` suite should remain in place for
general _component level_ UI tests for the field itself, whereas the
other suite could run the integration tests and test the more complex UI
patterns that exist outside of the field component.
2025-02-05 17:03:35 -05:00
Alessio Gravili
09721d4c20 fix(next): viewing modified-only diff view containing localized arrays throws error (#11006)
Fixes https://github.com/payloadcms/payload/issues/11002

`buildVersionFields` was adding `null` version fields to the version fields array. When RenderVersionFieldsToDiff tried to render those, it threw an error.

This PR ensures no `null` fields are added, as `RenderVersionFieldsToDiff` can't process them. That way, those fields are properly skipped, which is the intent of `modifiedOnly`
2025-02-05 21:42:38 +00:00
Elliot DeNolf
834fdde088 chore(release): v3.21.0 [skip ci] 2025-02-05 14:15:51 -05:00
James Mikrut
45913e41f1 fix(richtext-lexical): removes css from jsx converter (#10997)
Our new Lexical -> JSX converter is great, but right now it can only be
used in environments that support CSS importing / bundling.

It was only that way because of a single import file which can be
removed and inlined, therefore, improving the versatility of the JSX
converter and making it more usable in a wider variety of runtimes.

---------

Co-authored-by: Germán Jabloñski <43938777+GermanJablo@users.noreply.github.com>
2025-02-05 14:03:41 -05:00
Paul
42da87b6e9 fix(plugin-search): deleting docs even when there's a published version (#10993)
Fixes https://github.com/payloadcms/payload/issues/9770

If you had a published document but then created a new draft it would
delete the search doc, this PR adds an additional find to check if an
existing published doc exists before deleting the search doc.

Also adds a few jsdocs to plugin config
2025-02-05 10:14:17 -05:00
Jarrod Flesch
2a1ddf1e89 fix(plugin-multi-tenant): incorrect tenant selection with postgres (#10992)
### What
1. List view not working when clearing tenant selection (you would see a
NaN error)
2. Tenant selector would reset to the first option when loading a
document

### Why
1. Using parseFloat on the _ALL_ selection option
2. A was mismatch in ID types was causing the selector to never find a
matching option, thus resetting it to the first option

### How
1. Check if cookie isNumber before parsing
2. Do not cast select option values to string anymore

Fixes https://github.com/payloadcms/payload/issues/9821
Fixes https://github.com/payloadcms/payload/issues/10980
2025-02-05 09:56:27 -05:00
Elliot DeNolf
8af8befbd4 ci: increase closed issue lock for inactivity to 7 days 2025-02-05 09:15:58 -05:00
James Mikrut
2118c6c47f feat: exposes helpful args to ts schema gen (#10984)
You can currently extend Payload's type generation if you provide
additional JSON schema definitions yourself.

But, Payload has helpful functions like `fieldsToJSONSchema` which would
be nice to easily re-use.

The only issue is that the `fieldsToJSONSchema` requires arguments which
are difficult to access from the context of plugins, etc. They should
really be provided at runtime to the `config.typescript.schema`
functions.

This PR does exactly that. Adds more args to the `schema` extension
point to make utility functions easier to re-use.
2025-02-04 20:12:07 -05:00
Jacob Fletcher
a07fd9eba3 docs: fixes dynamic, fully qualified live preview url args (#10985)
The snippet for generating a dynamic, fully qualified live preview url
was wrong. It was indicating there were two arguments passed to that
function, when in fact there is only one.
2025-02-04 16:57:16 -05:00
Jarrod Flesch
ea9abfdef3 fix: allow public errors to thread through on response (#10419)
### What?
When using `throw new APIResponse("Custom error message", 500, null,
true)` the error message is being replaced with the standard "Something
went wrong" message.

### Why?
We are not checking if the 4th argument (`isPublic`) is false before
masquerading the error message.

### How?
Adds a check for `!err.isPublic` before adjusting the outgoing message.
2025-02-04 18:10:40 +00:00
Elliot DeNolf
b671fd5a6d templates: set pnpm engines to version 9 (#10979)
pnpm v10 + sharp is having issues. Setting to v9 for now.
2025-02-04 10:49:33 -05:00
Boyan Bratvanov
ae0736b738 examples: multi-tenant seed script, readme and other improvements (#10702) 2025-02-04 09:09:26 -05:00
241 changed files with 3166 additions and 1649 deletions

4
.github/CODEOWNERS vendored
View File

@@ -8,14 +8,14 @@
/packages/email-*/src/ @denolfe @jmikrut @DanRibbens
/packages/storage-*/src/ @denolfe @jmikrut @DanRibbens
/packages/create-payload-app/src/ @denolfe @jmikrut @DanRibbens
/packages/eslint-*/ @denolfe @jmikrut @DanRibbens @AlessioGr
/packages/eslint-*/ @denolfe @jmikrut @DanRibbens @AlessioGr @GermanJablo
### Templates ###
/templates/_data/ @denolfe @jmikrut @DanRibbens
/templates/_template/ @denolfe @jmikrut @DanRibbens
### Build Files ###
**/tsconfig*.json @denolfe @jmikrut @DanRibbens @AlessioGr
**/tsconfig*.json @denolfe @jmikrut @DanRibbens @AlessioGr @GermanJablo
**/jest.config.js @denolfe @jmikrut @DanRibbens @AlessioGr
### Root ###

View File

@@ -17,7 +17,7 @@ jobs:
uses: dessant/lock-threads@v5
with:
process-only: 'issues'
issue-inactive-days: '1'
issue-inactive-days: '7'
exclude-any-issue-labels: 'status: awaiting-reply'
log-output: true
issue-comment: >

View File

@@ -654,6 +654,26 @@ const ExampleCollection = {
]}
/>
## useDocumentForm
The `useDocumentForm` hook works the same way as the [useForm](#useform) hook, but it always gives you access to the top-level `Form` of a document. This is useful if you need to access the document's `Form` context from within a child `Form`.
An example where this could happen would be custom components within lexical blocks, as lexical blocks initialize their own child `Form`.
```tsx
'use client'
import { useDocumentForm } from '@payloadcms/ui'
const MyComponent: React.FC = () => {
const { fields: parentDocumentFields } = useDocumentForm()
return (
<p>The document's Form has ${Object.keys(parentDocumentFields).length} fields</p>
)
}
```
## useCollapsible
The `useCollapsible` hook allows you to control parent collapsibles:

View File

@@ -158,6 +158,7 @@ The following options are available:
| **`beforeListTable`** | An array of components to inject _before_ the built-in List View's table |
| **`afterList`** | An array of components to inject _after_ the built-in List View |
| **`afterListTable`** | An array of components to inject _after_ the built-in List View's table |
| **`listControlsMenu`** | An array of components to render as buttons within a menu next to the List Controls (after the Columns and Filters options) |
| **`Description`** | A component to render below the Collection label in the List View. An alternative to the `admin.description` property. |
| **`edit.SaveButton`** | Replace the default Save Button with a Custom Component. [Drafts](../versions/drafts) must be disabled. |
| **`edit.SaveDraftButton`** | Replace the default Save Draft Button with a Custom Component. [Drafts](../versions/drafts) must be enabled and autosave must be disabled. |

View File

@@ -112,7 +112,7 @@ The following arguments are provided to the `url` function:
If your application requires a fully qualified URL, such as within deploying to Vercel Preview Deployments, you can use the `req` property to build this URL:
```ts
url: (doc, { req }) => `${req.protocol}//${req.host}/${doc.slug}` // highlight-line
url: ({ data, req }) => `${req.protocol}//${req.host}/${data.slug}` // highlight-line
```
### Breakpoints

View File

@@ -10,11 +10,17 @@ To spin up this example locally, follow these steps:
- `npx create-payload-app --example multi-tenant`
2. `pnpm dev`, `yarn dev` or `npm run dev` to start the server
2. `cp .env.example .env` to copy the example environment variables
3. `pnpm dev`, `yarn dev` or `npm run dev` to start the server
- Press `y` when prompted to seed the database
3. `open http://localhost:3000` to access the home page
4. `open http://localhost:3000/admin` to access the admin panel
- Login with email `demo@payloadcms.com` and password `demo`
4. `open http://localhost:3000` to access the home page
5. `open http://localhost:3000/admin` to access the admin panel
### Default users
The seed script seeds 3 tenants.
Login with email `demo@payloadcms.com` and password `demo`
## How it works
@@ -28,7 +34,7 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc
- #### Users
The `users` collection is auth-enabled and encompass both app-wide and tenant-scoped users based on the value of their `roles` and `tenants` fields. Users with the role `super-admin` can manage your entire application, while users with the _tenant role_ of `admin` have limited access to the platform and can manage only the tenant(s) they are assigned to, see [Tenants](#tenants) for more details.
The `users` collection is auth-enabled and encompasses both app-wide and tenant-scoped users based on the value of their `roles` and `tenants` fields. Users with the role `super-admin` can manage your entire application, while users with the _tenant role_ of `admin` have limited access to the platform and can manage only the tenant(s) they are assigned to, see [Tenants](#tenants) for more details.
For additional help with authentication, see the official [Auth Example](https://github.com/payloadcms/payload/tree/main/examples/cms#readme) or the [Authentication](https://payloadcms.com/docs/authentication/overview#authentication-overview) docs.
@@ -40,13 +46,13 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc
**Domain-based Tenant Setting**:
This example also supports domain-based tenant selection, where tenants can be associated with a specific domain. If a tenant is associated with a domain (e.g., `gold.localhost.com:3000`), when a user logs in from that domain, they will be automatically scoped to the matching tenant. This is accomplished through an optional `afterLogin` hook that sets a `payload-tenant` cookie based on the domain.
This example also supports domain-based tenant selection, where tenants can be associated with a specific domain. If a tenant is associated with a domain (e.g., `gold.test:3000`), when a user logs in from that domain, they will be automatically scoped to the matching tenant. This is accomplished through an optional `afterLogin` hook that sets a `payload-tenant` cookie based on the domain.
The seed script seeds 3 tenants, for the domain portion of the example to function properly you will need to add the following entries to your systems `/etc/hosts` file:
For the domain portion of the example to function properly, you will need to add the following entries to your system's `/etc/hosts` file:
- gold.localhost.com:3000
- silver.localhost.com:3000
- bronze.localhost.com:3000
```
127.0.0.1 gold.test silver.test bronze.test
```
- #### Pages
@@ -54,7 +60,7 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc
## Access control
Basic role-based access control is setup to determine what users can and cannot do based on their roles, which are:
Basic role-based access control is set up to determine what users can and cannot do based on their roles, which are:
- `super-admin`: They can access the Payload admin panel to manage your multi-tenant application. They can see all tenants and make all operations.
- `user`: They can only access the Payload admin panel if they are a tenant-admin, in which case they have a limited access to operations based on their tenant (see below).

View File

@@ -10,10 +10,10 @@ export default async ({ params: paramsPromise }: { params: Promise<{ slug: strin
<p>When you visit a tenant by domain, the domain is used to determine the tenant.</p>
<p>
For example, visiting{' '}
<a href="http://gold.localhost.com:3000/tenant-domains/login">
http://gold.localhost.com:3000/tenant-domains/login
<a href="http://gold.test:3000/tenant-domains/login">
http://gold.test:3000/tenant-domains/login
</a>{' '}
will show the tenant with the domain "gold.localhost.com".
will show the tenant with the domain "gold.test".
</p>
<h2>Slugs</h2>

View File

@@ -5,7 +5,7 @@ import { Access } from 'payload'
/**
* Tenant admins and super admins can will be allowed access
*/
export const superAdminOrTeanantAdminAccess: Access = ({ req }) => {
export const superAdminOrTenantAdminAccess: Access = ({ req }) => {
if (!req.user) {
return false
}

View File

@@ -1,15 +1,15 @@
import type { CollectionConfig } from 'payload'
import { ensureUniqueSlug } from './hooks/ensureUniqueSlug'
import { superAdminOrTeanantAdminAccess } from '@/collections/Pages/access/superAdminOrTenantAdmin'
import { superAdminOrTenantAdminAccess } from '@/collections/Pages/access/superAdminOrTenantAdmin'
export const Pages: CollectionConfig = {
slug: 'pages',
access: {
create: superAdminOrTeanantAdminAccess,
delete: superAdminOrTeanantAdminAccess,
create: superAdminOrTenantAdminAccess,
delete: superAdminOrTenantAdminAccess,
read: () => true,
update: superAdminOrTeanantAdminAccess,
update: superAdminOrTenantAdminAccess,
},
admin: {
useAsTitle: 'title',

View File

@@ -1,6 +1,33 @@
import type { MigrateUpArgs } from '@payloadcms/db-mongodb'
export async function up({ payload }: MigrateUpArgs): Promise<void> {
const tenant1 = await payload.create({
collection: 'tenants',
data: {
name: 'Tenant 1',
slug: 'gold',
domain: 'gold.test',
},
})
const tenant2 = await payload.create({
collection: 'tenants',
data: {
name: 'Tenant 2',
slug: 'silver',
domain: 'silver.test',
},
})
const tenant3 = await payload.create({
collection: 'tenants',
data: {
name: 'Tenant 3',
slug: 'bronze',
domain: 'bronze.test',
},
})
await payload.create({
collection: 'users',
data: {
@@ -10,47 +37,16 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
},
})
const tenant1 = await payload.create({
collection: 'tenants',
data: {
name: 'Tenant 1',
slug: 'gold',
domain: 'gold.localhost.com',
},
})
const tenant2 = await payload.create({
collection: 'tenants',
data: {
name: 'Tenant 2',
slug: 'silver',
domain: 'silver.localhost.com',
},
})
const tenant3 = await payload.create({
collection: 'tenants',
data: {
name: 'Tenant 3',
slug: 'bronze',
domain: 'bronze.localhost.com',
},
})
await payload.create({
collection: 'users',
data: {
email: 'tenant1@payloadcms.com',
password: 'test',
password: 'demo',
tenants: [
{
roles: ['tenant-admin'],
tenant: tenant1.id,
},
// {
// roles: ['tenant-admin'],
// tenant: tenant2.id,
// },
],
username: 'tenant1',
},
@@ -60,7 +56,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
collection: 'users',
data: {
email: 'tenant2@payloadcms.com',
password: 'test',
password: 'demo',
tenants: [
{
roles: ['tenant-admin'],
@@ -75,7 +71,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
collection: 'users',
data: {
email: 'tenant3@payloadcms.com',
password: 'test',
password: 'demo',
tenants: [
{
roles: ['tenant-admin'],
@@ -90,7 +86,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
collection: 'users',
data: {
email: 'multi-admin@payloadcms.com',
password: 'test',
password: 'demo',
tenants: [
{
roles: ['tenant-admin'],
@@ -105,7 +101,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
tenant: tenant3.id,
},
],
username: 'tenant3',
username: 'multi-admin',
},
})

View File

@@ -1,6 +1,6 @@
{
"name": "payload-monorepo",
"version": "3.20.0",
"version": "3.21.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.20.0",
"version": "3.21.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -54,6 +54,7 @@ const generateEnvContent = (
.filter((line) => line.includes('=') && !line.startsWith('#'))
.forEach((line) => {
const [key, value] = line.split('=')
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
envVars[key] = value
})

View File

@@ -224,12 +224,12 @@ function insertBeforeAndAfter(content: string, loc: Loc): string {
}
// insert ) after end
lines[end.line - 1] = insert(lines[end.line - 1], end.column, ')')
lines[end.line - 1] = insert(lines[end.line - 1]!, end.column, ')')
// insert withPayload before start
if (start.line === end.line) {
lines[end.line - 1] = insert(lines[end.line - 1], start.column, 'withPayload(')
lines[end.line - 1] = insert(lines[end.line - 1]!, start.column, 'withPayload(')
} else {
lines[start.line - 1] = insert(lines[start.line - 1], start.column, 'withPayload(')
lines[start.line - 1] = insert(lines[start.line - 1]!, start.column, 'withPayload(')
}
return lines.join('\n')

View File

@@ -1,7 +1,3 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
/* TODO: remove the following lines */
"noUncheckedIndexedAccess": false,
},
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.20.0",
"version": "3.21.0",
"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.20.0",
"version": "3.21.0",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.20.0",
"version": "3.21.0",
"description": "The officially supported SQLite database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-vercel-postgres",
"version": "3.20.0",
"version": "3.21.0",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.20.0",
"version": "3.21.0",
"description": "A library of shared functions used by different payload database adapters",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -16,7 +16,9 @@ export async function createGlobal<T extends Record<string, unknown>>(
const tableName = this.tableNameMap.get(toSnakeCase(globalConfig.slug))
const result = await upsertRow<T>({
data.createdAt = new Date().toISOString()
const result = await upsertRow<{ globalType: string } & T>({
adapter: this,
data,
db,
@@ -26,5 +28,7 @@ export async function createGlobal<T extends Record<string, unknown>>(
tableName,
})
result.globalType = slug
return result
}

View File

@@ -97,6 +97,7 @@ export const transformArray = ({
data: arrayRow,
fieldPrefix: '',
fields: field.flattenedFields,
insideArrayOrBlock: true,
locales: newRow.locales,
numbers,
parentTableName: arrayTableName,

View File

@@ -101,6 +101,7 @@ export const transformBlocks = ({
data: blockRow,
fieldPrefix: '',
fields: matchedBlock.flattenedFields,
insideArrayOrBlock: true,
locales: newRow.locales,
numbers,
parentTableName: blockTableName,

View File

@@ -42,6 +42,10 @@ type Args = {
fieldPrefix: string
fields: FlattenedField[]
forcedLocale?: string
/**
* Tracks whether the current traversion context is from array or block.
*/
insideArrayOrBlock?: boolean
locales: {
[locale: string]: Record<string, unknown>
}
@@ -77,6 +81,7 @@ export const traverseFields = ({
fieldPrefix,
fields,
forcedLocale,
insideArrayOrBlock = false,
locales,
numbers,
parentTableName,
@@ -230,6 +235,7 @@ export const traverseFields = ({
fieldPrefix: `${fieldName}_`,
fields: field.flattenedFields,
forcedLocale: localeKey,
insideArrayOrBlock,
locales,
numbers,
parentTableName,
@@ -258,6 +264,7 @@ export const traverseFields = ({
existingLocales,
fieldPrefix: `${fieldName}_`,
fields: field.flattenedFields,
insideArrayOrBlock,
locales,
numbers,
parentTableName,
@@ -420,7 +427,7 @@ export const traverseFields = ({
Object.entries(data[field.name]).forEach(([localeKey, localeData]) => {
if (Array.isArray(localeData)) {
const newRows = transformSelects({
id: data._uuid || data.id,
id: insideArrayOrBlock ? data._uuid || data.id : undefined,
data: localeData,
locale: localeKey,
})
@@ -431,7 +438,7 @@ export const traverseFields = ({
}
} else if (Array.isArray(data[field.name])) {
const newRows = transformSelects({
id: data._uuid || data.id,
id: insideArrayOrBlock ? data._uuid || data.id : undefined,
data: data[field.name],
locale: withinArrayOrBlockLocale,
})
@@ -472,8 +479,9 @@ export const traverseFields = ({
}
valuesToTransform.forEach(({ localeKey, ref, value }) => {
let formattedValue = value
if (typeof value !== 'undefined') {
let formattedValue = value
if (value && field.type === 'point' && adapter.name !== 'sqlite') {
formattedValue = sql`ST_GeomFromGeoJSON(${JSON.stringify(value)})`
}
@@ -483,12 +491,16 @@ export const traverseFields = ({
formattedValue = new Date(value).toISOString()
} else if (value instanceof Date) {
formattedValue = value.toISOString()
} else if (fieldName === 'updatedAt') {
// let the db handle this
formattedValue = new Date().toISOString()
}
}
}
if (field.type === 'date' && fieldName === 'updatedAt') {
// let the db handle this
formattedValue = new Date().toISOString()
}
if (typeof formattedValue !== 'undefined') {
if (localeKey) {
ref[localeKey][fieldName] = formattedValue
} else {

View File

@@ -17,7 +17,7 @@ export async function updateGlobal<T extends Record<string, unknown>>(
const existingGlobal = await db.query[tableName].findFirst({})
const result = await upsertRow<T>({
const result = await upsertRow<{ globalType: string } & T>({
...(existingGlobal ? { id: existingGlobal.id, operation: 'update' } : { operation: 'create' }),
adapter: this,
data,
@@ -28,5 +28,7 @@ export async function updateGlobal<T extends Record<string, unknown>>(
tableName,
})
result.globalType = slug
return result
}

View File

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

View File

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

View File

@@ -37,7 +37,7 @@
"eslint-plugin-jest-dom": "5.4.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-perfectionist": "3.9.1",
"eslint-plugin-react-hooks": "5.0.0",
"eslint-plugin-react-hooks": "0.0.0-experimental-a4b2d0d5-20250203",
"eslint-plugin-regexp": "2.6.0",
"globals": "15.12.0",
"typescript": "5.7.3",

View File

@@ -36,7 +36,7 @@
"eslint-plugin-jest-dom": "5.4.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-perfectionist": "3.9.1",
"eslint-plugin-react-hooks": "5.0.0",
"eslint-plugin-react-hooks": "0.0.0-experimental-a4b2d0d5-20250203",
"eslint-plugin-regexp": "2.6.0",
"globals": "15.12.0",
"typescript": "5.7.3",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.20.0",
"version": "3.21.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-react",
"version": "3.20.0",
"version": "3.21.0",
"description": "The official React SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview-vue",
"version": "3.20.0",
"version": "3.21.0",
"description": "The official Vue SDK for Payload Live Preview",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.20.0",
"version": "3.21.0",
"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.20.0",
"version": "3.21.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -91,6 +91,20 @@ export const RootLayout = async ({
importMap,
})
if (
clientConfig.localization &&
config.localization &&
typeof config.localization.filterAvailableLocales === 'function'
) {
clientConfig.localization.locales = (
await config.localization.filterAvailableLocales({
locales: config.localization.locales,
req,
})
).map(({ toString, ...rest }) => rest)
clientConfig.localization.localeCodes = config.localization.locales.map(({ code }) => code)
}
const locale = await getRequestLocale({
req,
})

View File

@@ -91,6 +91,7 @@ export const ForgotPasswordForm: React.FC = () => {
text(value, {
name: 'username',
type: 'text',
blockData: {},
data: {},
event: 'onChange',
preferences: { fields: {} },
@@ -120,6 +121,7 @@ export const ForgotPasswordForm: React.FC = () => {
email(value, {
name: 'email',
type: 'email',
blockData: {},
data: {},
event: 'onChange',
preferences: { fields: {} },

View File

@@ -33,6 +33,15 @@ export const renderListViewSlots = ({
})
}
if (collectionConfig.admin.components?.listControlsMenu) {
result.ListControlsMenu = RenderServerComponent({
clientProps,
Component: collectionConfig.admin.components.listControlsMenu,
importMap: payload.importMap,
serverProps,
})
}
if (collectionConfig.admin.components?.afterListTable) {
result.AfterListTable = RenderServerComponent({
clientProps,

View File

@@ -113,7 +113,7 @@ export const buildVersionFields = ({
versionField.fieldByLocale = {}
for (const locale of selectedLocales) {
versionField.fieldByLocale[locale] = buildVersionField({
const localizedVersionField = buildVersionField({
clientField: clientField as ClientField,
clientSchemaMap,
comparisonValue: comparisonValue?.[locale],
@@ -133,12 +133,12 @@ export const buildVersionFields = ({
selectedLocales,
versionValue: versionValue?.[locale],
})
if (!versionField.fieldByLocale[locale]) {
continue
if (localizedVersionField) {
versionField.fieldByLocale[locale] = localizedVersionField
}
}
} else {
versionField.field = buildVersionField({
const baseVersionField = buildVersionField({
clientField: clientField as ClientField,
clientSchemaMap,
comparisonValue,
@@ -158,8 +158,8 @@ export const buildVersionFields = ({
versionValue,
})
if (!versionField.field) {
continue
if (baseVersionField) {
versionField.field = baseVersionField
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/payload-cloud",
"version": "3.20.0",
"version": "3.21.0",
"description": "The official Payload Cloud plugin",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

View File

@@ -68,9 +68,16 @@ export type BuildFormStateArgs = {
data?: Data
docPermissions: SanitizedDocumentPermissions | undefined
docPreferences: DocumentPreferences
/**
* In case `formState` is not the top-level, document form state, this can be passed to
* provide the top-level form state.
*/
documentFormState?: FormState
fallbackLocale?: false | TypedLocale
formState?: FormState
id?: number | string
initialBlockData?: Data
initialBlockFormState?: FormState
/*
If not i18n was passed, the language can be passed to init i18n
*/

View File

@@ -34,6 +34,7 @@ export const generatePasswordSaltHash = async ({
const validationResult = password(passwordToSet, {
name: 'password',
type: 'text',
blockData: {},
data: {},
event: 'submit',
preferences: { fields: {} },

View File

@@ -30,6 +30,7 @@ export function iterateCollections({
})
addToImportMap(collection.admin?.components?.afterList)
addToImportMap(collection.admin?.components?.listControlsMenu)
addToImportMap(collection.admin?.components?.afterListTable)
addToImportMap(collection.admin?.components?.beforeList)
addToImportMap(collection.admin?.components?.beforeListTable)

View File

@@ -310,6 +310,7 @@ export type CollectionAdminOptions = {
*/
Upload?: CustomUpload
}
listControlsMenu?: CustomComponent[]
views?: {
/**
* Set to a React component to replace the entire Edit View, including all nested routes.

View File

@@ -1,6 +1,7 @@
import type {
DefaultTranslationKeys,
DefaultTranslationsObject,
I18n,
I18nClient,
I18nOptions,
TFunction,
@@ -469,6 +470,14 @@ export type BaseLocalizationConfig = {
* @default true
*/
fallback?: boolean
/**
* Define a function to filter the locales made available in Payload admin UI
* based on user.
*/
filterAvailableLocales?: (args: {
locales: Locale[]
req: PayloadRequest
}) => Locale[] | Promise<Locale[]>
}
export type LocalizationConfigWithNoLabels = Prettify<
@@ -1122,7 +1131,16 @@ export type Config = {
* Allows you to modify the base JSON schema that is generated during generate:types. This JSON schema will be used
* to generate the TypeScript interfaces.
*/
schema?: Array<(args: { jsonSchema: JSONSchema4 }) => JSONSchema4>
schema?: Array<
(args: {
collectionIDFieldTypes: {
[key: string]: 'number' | 'string'
}
config: SanitizedConfig
i18n: I18n
jsonSchema: JSONSchema4
}) => JSONSchema4
>
}
/**
* Customize the handling of incoming file uploads for collections that have uploads enabled.

View File

@@ -133,7 +133,13 @@ import type {
TextareaFieldValidation,
} from '../../index.js'
import type { DocumentPreferences } from '../../preferences/types.js'
import type { DefaultValue, Operation, PayloadRequest, Where } from '../../types/index.js'
import type {
DefaultValue,
JsonObject,
Operation,
PayloadRequest,
Where,
} from '../../types/index.js'
import type {
NumberFieldManyValidation,
NumberFieldSingleValidation,
@@ -148,6 +154,10 @@ import type {
} from '../validations.js'
export type FieldHookArgs<TData extends TypeWithID = any, TValue = any, TSiblingData = any> = {
/**
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData: JsonObject | undefined
/** The collection which the field belongs to. If the field belongs to a global, this will be null. */
collection: null | SanitizedCollectionConfig
context: RequestContext
@@ -212,7 +222,11 @@ export type FieldHook<TData extends TypeWithID = any, TValue = any, TSiblingData
export type FieldAccess<TData extends TypeWithID = any, TSiblingData = any> = (args: {
/**
* The incoming data used to `create` or `update` the document with. `data` is undefined during the `read` operation.
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData?: JsonObject | undefined
/**
* The incoming, top-level document data used to `create` or `update` the document with.
*/
data?: Partial<TData>
/**
@@ -231,13 +245,33 @@ export type FieldAccess<TData extends TypeWithID = any, TSiblingData = any> = (a
siblingData?: Partial<TSiblingData>
}) => boolean | Promise<boolean>
//TODO: In 4.0, we should replace the three parameters of the condition function with a single, named parameter object
export type Condition<TData extends TypeWithID = any, TSiblingData = any> = (
/**
* The top-level document data
*/
data: Partial<TData>,
/**
* Immediately adjacent data to this field. For example, if this is a `group` field, then `siblingData` will be the other fields within the group.
*/
siblingData: Partial<TSiblingData>,
{ user }: { user: PayloadRequest['user'] },
{
blockData,
user,
}: {
/**
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData: Partial<TData>
user: PayloadRequest['user']
},
) => boolean
export type FilterOptionsProps<TData = any> = {
/**
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData: TData
/**
* An object containing the full collection or global document currently being edited.
*/
@@ -348,6 +382,11 @@ export type LabelsClient = {
}
export type BaseValidateOptions<TData, TSiblingData, TValue> = {
/**
/**
* The data of the nearest parent block. If the field is not within a block, `blockData` will be equal to `undefined`.
*/
blockData: Partial<TData>
collectionSlug?: string
data: Partial<TData>
event?: 'onChange' | 'submit'

View File

@@ -11,6 +11,10 @@ import { getFieldPathsModified as getFieldPaths } from '../../getFieldPaths.js'
import { traverseFields } from './traverseFields.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
@@ -33,6 +37,7 @@ type Args = {
// - Execute field hooks
export const promise = async ({
blockData,
collection,
context,
data,
@@ -69,6 +74,7 @@ export const promise = async ({
await priorHook
const hookedValue = await currentHook({
blockData,
collection,
context,
data,
@@ -104,6 +110,7 @@ export const promise = async ({
rows.forEach((row, rowIndex) => {
promises.push(
traverseFields({
blockData,
collection,
context,
data,
@@ -142,6 +149,7 @@ export const promise = async ({
if (block) {
promises.push(
traverseFields({
blockData: siblingData?.[field.name]?.[rowIndex],
collection,
context,
data,
@@ -171,6 +179,7 @@ export const promise = async ({
case 'collapsible':
case 'row': {
await traverseFields({
blockData,
collection,
context,
data,
@@ -193,6 +202,7 @@ export const promise = async ({
case 'group': {
await traverseFields({
blockData,
collection,
context,
data,
@@ -269,6 +279,7 @@ export const promise = async ({
}
await traverseFields({
blockData,
collection,
context,
data,
@@ -291,6 +302,7 @@ export const promise = async ({
case 'tabs': {
await traverseFields({
blockData,
collection,
context,
data,

View File

@@ -7,6 +7,10 @@ import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
@@ -25,6 +29,7 @@ type Args = {
}
export const traverseFields = async ({
blockData,
collection,
context,
data,
@@ -46,6 +51,7 @@ export const traverseFields = async ({
fields.forEach((field, fieldIndex) => {
promises.push(
promise({
blockData,
collection,
context,
data,

View File

@@ -19,6 +19,10 @@ import { relationshipPopulationPromise } from './relationshipPopulationPromise.j
import { traverseFields } from './traverseFields.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
currentDepth: number
@@ -60,6 +64,7 @@ type Args = {
// - Populate relationships
export const promise = async ({
blockData,
collection,
context,
currentDepth,
@@ -236,6 +241,7 @@ export const promise = async ({
const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) =>
(async () => {
const hookedValue = await currentHook({
blockData,
collection,
context,
currentDepth,
@@ -266,6 +272,7 @@ export const promise = async ({
await Promise.all(hookPromises)
} else {
const hookedValue = await currentHook({
blockData,
collection,
context,
currentDepth,
@@ -301,6 +308,7 @@ export const promise = async ({
? true
: await field.access.read({
id: doc.id as number | string,
blockData,
data: doc,
doc,
req,
@@ -364,6 +372,7 @@ export const promise = async ({
if (Array.isArray(rows)) {
rows.forEach((row, rowIndex) => {
traverseFields({
blockData,
collection,
context,
currentDepth,
@@ -397,6 +406,7 @@ export const promise = async ({
if (Array.isArray(localeRows)) {
localeRows.forEach((row, rowIndex) => {
traverseFields({
blockData,
collection,
context,
currentDepth,
@@ -476,6 +486,7 @@ export const promise = async ({
if (block) {
traverseFields({
blockData: row,
collection,
context,
currentDepth,
@@ -515,6 +526,7 @@ export const promise = async ({
if (block) {
traverseFields({
blockData: row,
collection,
context,
currentDepth,
@@ -554,6 +566,7 @@ export const promise = async ({
case 'collapsible':
case 'row': {
traverseFields({
blockData,
collection,
context,
currentDepth,
@@ -595,6 +608,7 @@ export const promise = async ({
const groupSelect = select?.[field.name]
traverseFields({
blockData,
collection,
context,
currentDepth,
@@ -747,6 +761,7 @@ export const promise = async ({
}
traverseFields({
blockData,
collection,
context,
currentDepth,
@@ -780,6 +795,7 @@ export const promise = async ({
case 'tabs': {
traverseFields({
blockData,
collection,
context,
currentDepth,

View File

@@ -13,6 +13,10 @@ import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
currentDepth: number
@@ -45,6 +49,7 @@ type Args = {
}
export const traverseFields = ({
blockData,
collection,
context,
currentDepth,
@@ -75,6 +80,7 @@ export const traverseFields = ({
fields.forEach((field, fieldIndex) => {
fieldPromises.push(
promise({
blockData,
collection,
context,
currentDepth,

View File

@@ -4,7 +4,7 @@ import type { ValidationFieldError } from '../../../errors/index.js'
import type { SanitizedGlobalConfig } from '../../../globals/config/types.js'
import type { RequestContext } from '../../../index.js'
import type { JsonObject, Operation, PayloadRequest } from '../../../types/index.js'
import type { Field, TabAsField } from '../../config/types.js'
import type { Field, TabAsField, Validate } from '../../config/types.js'
import { MissingEditorProp } from '../../../errors/index.js'
import { deepMergeWithSourceArrays } from '../../../utilities/deepMerge.js'
@@ -16,6 +16,10 @@ import { getExistingRowDoc } from './getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
@@ -48,6 +52,7 @@ type Args = {
export const promise = async ({
id,
blockData,
collection,
context,
data,
@@ -77,7 +82,7 @@ export const promise = async ({
})
const passesCondition = field.admin?.condition
? Boolean(field.admin.condition(data, siblingData, { user: req.user }))
? Boolean(field.admin.condition(data, siblingData, { blockData, user: req.user }))
: true
let skipValidationFromHere = skipValidation || !passesCondition
const { localization } = req.payload.config
@@ -102,6 +107,7 @@ export const promise = async ({
await priorHook
const hookedValue = await currentHook({
blockData,
collection,
context,
data,
@@ -139,22 +145,27 @@ export const promise = async ({
}
}
const validationResult = await field.validate(
valueToValidate as never,
{
...field,
id,
collectionSlug: collection?.slug,
data: deepMergeWithSourceArrays(doc, data),
event: 'submit',
jsonError,
operation,
preferences: { fields: {} },
previousValue: siblingDoc[field.name],
req,
siblingData: deepMergeWithSourceArrays(siblingDoc, siblingData),
} as any,
)
const validateFn: Validate<object, object, object, object> = field.validate as Validate<
object,
object,
object,
object
>
const validationResult = await validateFn(valueToValidate as never, {
...field,
id,
blockData,
collectionSlug: collection?.slug,
data: deepMergeWithSourceArrays(doc, data),
event: 'submit',
// @ts-expect-error
jsonError,
operation,
preferences: { fields: {} },
previousValue: siblingDoc[field.name],
req,
siblingData: deepMergeWithSourceArrays(siblingDoc, siblingData),
})
if (typeof validationResult === 'string') {
const label = getTranslatedLabel(field?.label || field?.name, req.i18n)
@@ -217,6 +228,7 @@ export const promise = async ({
promises.push(
traverseFields({
id,
blockData,
collection,
context,
data,
@@ -268,6 +280,7 @@ export const promise = async ({
promises.push(
traverseFields({
id,
blockData: row,
collection,
context,
data,
@@ -301,6 +314,7 @@ export const promise = async ({
case 'row': {
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -339,6 +353,7 @@ export const promise = async ({
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -455,6 +470,7 @@ export const promise = async ({
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -481,6 +497,7 @@ export const promise = async ({
case 'tabs': {
await traverseFields({
id,
blockData,
collection,
context,
data,

View File

@@ -8,6 +8,10 @@ import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
type Args = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: JsonObject
@@ -51,6 +55,7 @@ type Args = {
*/
export const traverseFields = async ({
id,
blockData,
collection,
context,
data,
@@ -76,6 +81,7 @@ export const traverseFields = async ({
promises.push(
promise({
id,
blockData,
collection,
context,
data,

View File

@@ -9,6 +9,10 @@ import { runBeforeDuplicateHooks } from './runHook.js'
import { traverseFields } from './traverseFields.js'
type Args<T> = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
doc: T
@@ -25,6 +29,7 @@ type Args<T> = {
export const promise = async <T>({
id,
blockData,
collection,
context,
doc,
@@ -63,6 +68,7 @@ export const promise = async <T>({
const localizedValues = await localizedValuesPromise
const beforeDuplicateArgs: FieldHookArgs = {
blockData,
collection,
context,
data: doc,
@@ -96,6 +102,7 @@ export const promise = async <T>({
siblingDoc[field.name] = localeData
} else {
const beforeDuplicateArgs: FieldHookArgs = {
blockData,
collection,
context,
data: doc,
@@ -143,6 +150,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -177,6 +185,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData: row,
collection,
context,
doc,
@@ -199,6 +208,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -234,6 +244,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -270,6 +281,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData: row,
collection,
context,
doc,
@@ -300,6 +312,7 @@ export const promise = async <T>({
await traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -324,6 +337,7 @@ export const promise = async <T>({
await traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -347,6 +361,7 @@ export const promise = async <T>({
case 'row': {
await traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -367,6 +382,7 @@ export const promise = async <T>({
case 'tab': {
await traverseFields({
id,
blockData,
collection,
context,
doc,
@@ -386,6 +402,7 @@ export const promise = async <T>({
case 'tabs': {
await traverseFields({
id,
blockData,
collection,
context,
doc,

View File

@@ -6,6 +6,10 @@ import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
type Args<T> = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
doc: T
@@ -21,6 +25,7 @@ type Args<T> = {
export const traverseFields = async <T>({
id,
blockData,
collection,
context,
doc,
@@ -38,6 +43,7 @@ export const traverseFields = async <T>({
promises.push(
promise({
id,
blockData,
collection,
context,
doc,

View File

@@ -14,6 +14,10 @@ import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc.js'
import { traverseFields } from './traverseFields.js'
type Args<T> = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: T
@@ -47,6 +51,7 @@ type Args<T> = {
export const promise = async <T>({
id,
blockData,
collection,
context,
data,
@@ -270,6 +275,7 @@ export const promise = async <T>({
await priorHook
const hookedValue = await currentHook({
blockData,
collection,
context,
data,
@@ -298,7 +304,7 @@ export const promise = async <T>({
if (field.access && field.access[operation]) {
const result = overrideAccess
? true
: await field.access[operation]({ id, data, doc, req, siblingData })
: await field.access[operation]({ id, blockData, data, doc, req, siblingData })
if (!result) {
delete siblingData[field.name]
@@ -335,6 +341,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData,
collection,
context,
data,
@@ -375,6 +382,7 @@ export const promise = async <T>({
promises.push(
traverseFields({
id,
blockData: row,
collection,
context,
data,
@@ -404,6 +412,7 @@ export const promise = async <T>({
case 'row': {
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -437,6 +446,7 @@ export const promise = async <T>({
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -522,6 +532,7 @@ export const promise = async <T>({
await traverseFields({
id,
blockData,
collection,
context,
data,
@@ -544,6 +555,7 @@ export const promise = async <T>({
case 'tabs': {
await traverseFields({
id,
blockData,
collection,
context,
data,

View File

@@ -7,6 +7,10 @@ import type { Field, TabAsField } from '../../config/types.js'
import { promise } from './promise.js'
type Args<T> = {
/**
* Data of the nearest parent block. If no parent block exists, this will be the `undefined`
*/
blockData?: JsonObject
collection: null | SanitizedCollectionConfig
context: RequestContext
data: T
@@ -32,6 +36,7 @@ type Args<T> = {
export const traverseFields = async <T>({
id,
blockData,
collection,
context,
data,
@@ -53,6 +58,7 @@ export const traverseFields = async <T>({
promises.push(
promise({
id,
blockData,
collection,
context,
data,

View File

@@ -510,7 +510,7 @@ const validateFilterOptions: Validate<
RelationshipField | UploadField
> = async (
value,
{ id, data, filterOptions, relationTo, req, req: { payload, t, user }, siblingData },
{ id, blockData, data, filterOptions, relationTo, req, req: { payload, t, user }, siblingData },
) => {
if (typeof filterOptions !== 'undefined' && value) {
const options: {
@@ -527,6 +527,7 @@ const validateFilterOptions: Validate<
typeof filterOptions === 'function'
? await filterOptions({
id,
blockData,
data,
relationTo: collection,
req,

View File

@@ -244,6 +244,11 @@ export const updateOperation = async <
// /////////////////////////////////////
if (!shouldSaveDraft) {
// Ensure global has createdAt
if (!result.createdAt) {
result.createdAt = new Date().toISOString()
}
if (globalExists) {
result = await payload.db.updateGlobal({
slug,

View File

@@ -217,7 +217,9 @@ function entityOrFieldToJsDocs({
description = entity?.admin?.description?.[i18n.language]
}
} else if (typeof entity?.admin?.description === 'function' && i18n) {
description = entity?.admin?.description(i18n)
// do not evaluate description functions for generating JSDocs. The output of
// those can differ depending on where and when they are called, creating
// inconsistencies in the generated JSDocs.
}
}
return description
@@ -1102,7 +1104,7 @@ export function configToJSONSchema(
if (config?.typescript?.schema?.length) {
for (const schema of config.typescript.schema) {
jsonSchema = schema({ jsonSchema })
jsonSchema = schema({ collectionIDFieldTypes, config, i18n, jsonSchema })
}
}

View File

@@ -55,7 +55,7 @@ export const routeError = async ({
// Internal server errors can contain anything, including potentially sensitive data.
// Therefore, error details will be hidden from the response unless `config.debug` is `true`
if (!config.debug && status === httpStatus.INTERNAL_SERVER_ERROR) {
if (!config.debug && !err.isPublic && status === httpStatus.INTERNAL_SERVER_ERROR) {
response = formatErrors(new APIError('Something went wrong.'))
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-form-builder",
"version": "3.20.0",
"version": "3.21.0",
"description": "Form builder plugin for Payload CMS",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-multi-tenant",
"version": "3.20.0",
"version": "3.21.0",
"description": "Multi Tenant plugin for Payload",
"keywords": [
"payload",

View File

@@ -24,15 +24,21 @@ export const TenantField = (args: Props) => {
const hasSetValueRef = React.useRef(false)
React.useEffect(() => {
if (!hasSetValueRef.current && value) {
if (!hasSetValueRef.current) {
// set value on load
setTenant({ id: value, refresh: unique })
if (value && value !== selectedTenantID) {
setTenant({ id: value, refresh: unique })
} else {
// in the document view, the tenant field should always have a value
const defaultValue =
!selectedTenantID || selectedTenantID === SELECT_ALL
? options[0]?.value
: selectedTenantID
setTenant({ id: defaultValue, refresh: unique })
}
hasSetValueRef.current = true
} else if (selectedTenantID && selectedTenantID === SELECT_ALL && options?.[0]?.value) {
// in the document view, the tenant field should always have a value
setTenant({ id: options[0].value, refresh: unique })
} else if ((!value || value !== selectedTenantID) && selectedTenantID) {
// Update the field value when the tenant is changed
} else if ((!value || value !== selectedTenantID) && selectedTenantID !== SELECT_ALL) {
// Update the field on the document value when the tenant is changed
setValue(selectedTenantID)
}
}, [value, selectedTenantID, setTenant, setValue, options, unique])

View File

@@ -42,7 +42,7 @@ export const TenantSelector = ({ viewType }: { viewType?: ViewTypes }) => {
selectedTenantID
? selectedTenantID === SELECT_ALL
? undefined
: String(selectedTenantID)
: (selectedTenantID as string)
: undefined
}
/>

View File

@@ -0,0 +1,6 @@
export const defaults = {
tenantCollectionSlug: 'tenants',
tenantFieldName: 'tenant',
tenantsArrayFieldName: 'tenants',
tenantsArrayTenantFieldName: 'tenant',
}

View File

@@ -1,6 +1,7 @@
import { type RelationshipField } from 'payload'
import { APIError } from 'payload'
import { defaults } from '../../defaults.js'
import { getCollectionIDType } from '../../utilities/getCollectionIDType.js'
import { getTenantFromCookie } from '../../utilities/getTenantFromCookie.js'
@@ -12,10 +13,10 @@ type Args = {
unique: boolean
}
export const tenantField = ({
name,
name = defaults.tenantFieldName,
access = undefined,
debug,
tenantsCollectionSlug,
tenantsCollectionSlug = defaults.tenantCollectionSlug,
unique,
}: Args): RelationshipField => ({
name,

View File

@@ -1,27 +1,37 @@
import type { ArrayField, RelationshipField } from 'payload'
export const tenantsArrayField = (args: {
import { defaults } from '../../defaults.js'
type Args = {
arrayFieldAccess?: ArrayField['access']
rowFields?: ArrayField['fields']
tenantFieldAccess?: RelationshipField['access']
tenantsArrayFieldName: ArrayField['name']
tenantsArrayTenantFieldName: RelationshipField['name']
tenantsCollectionSlug: string
}): ArrayField => ({
name: args.tenantsArrayFieldName,
}
export const tenantsArrayField = ({
arrayFieldAccess,
rowFields,
tenantFieldAccess,
tenantsArrayFieldName = defaults.tenantsArrayFieldName,
tenantsArrayTenantFieldName = defaults.tenantsArrayFieldName,
tenantsCollectionSlug = defaults.tenantCollectionSlug,
}: Args): ArrayField => ({
name: tenantsArrayFieldName,
type: 'array',
access: args?.arrayFieldAccess,
access: arrayFieldAccess,
fields: [
{
name: args.tenantsArrayTenantFieldName,
name: tenantsArrayTenantFieldName,
type: 'relationship',
access: args.tenantFieldAccess,
access: tenantFieldAccess,
index: true,
relationTo: args.tenantsCollectionSlug,
relationTo: tenantsCollectionSlug,
required: true,
saveToJWT: true,
},
...(args?.rowFields || []),
...(rowFields || []),
],
saveToJWT: true,
})

View File

@@ -2,6 +2,7 @@ import type { CollectionConfig, Config } from 'payload'
import type { MultiTenantPluginConfig } from './types.js'
import { defaults } from './defaults.js'
import { tenantField } from './fields/tenantField/index.js'
import { tenantsArrayField } from './fields/tenantsArrayField/index.js'
import { addTenantCleanup } from './hooks/afterTenantDelete.js'
@@ -9,13 +10,6 @@ import { addCollectionAccess } from './utilities/addCollectionAccess.js'
import { addFilterOptionsToFields } from './utilities/addFilterOptionsToFields.js'
import { withTenantListFilter } from './utilities/withTenantListFilter.js'
const defaults = {
tenantCollectionSlug: 'tenants',
tenantFieldName: 'tenant',
tenantsArrayFieldName: 'tenants',
tenantsArrayTenantFieldName: 'tenant',
}
export const multiTenantPlugin =
<ConfigType>(pluginConfig: MultiTenantPluginConfig<ConfigType>) =>
(incomingConfig: Config): Config => {

View File

@@ -33,7 +33,7 @@ export const TenantSelectionProvider = async ({
})
tenantOptions = docs.map((doc) => ({
label: String(doc[useAsTitle]),
value: String(doc.id),
value: doc.id,
}))
} catch (_) {
// user likely does not have access
@@ -42,15 +42,17 @@ export const TenantSelectionProvider = async ({
const cookies = await getCookies()
let tenantCookie = cookies.get('payload-tenant')?.value
let initialValue = undefined
const isValidTenantCookie =
(tenantOptions.length > 1 && tenantCookie === SELECT_ALL) ||
tenantOptions.some((option) => option.value === tenantCookie)
if (isValidTenantCookie) {
initialValue = tenantCookie
if (tenantOptions.length > 1 && tenantCookie === SELECT_ALL) {
initialValue = SELECT_ALL
} else {
tenantCookie = undefined
initialValue = tenantOptions.length > 1 ? SELECT_ALL : tenantOptions[0]?.value
const matchingOption = tenantOptions.find((option) => String(option.value) === tenantCookie)
if (matchingOption) {
initialValue = matchingOption.value
} else {
tenantCookie = undefined
initialValue = tenantOptions.length > 1 ? SELECT_ALL : tenantOptions[0]?.value
}
}
return (

View File

@@ -1,4 +1,5 @@
import { parseCookies } from 'payload'
import { isNumber } from 'payload/shared'
/**
* A function that takes request headers and an idType and returns the current tenant ID from the cookie
@@ -13,5 +14,9 @@ export function getTenantFromCookie(
): null | number | string {
const cookies = parseCookies(headers)
const selectedTenant = cookies.get('payload-tenant') || null
return selectedTenant ? (idType === 'number' ? parseFloat(selectedTenant) : selectedTenant) : null
return selectedTenant
? idType === 'number' && isNumber(selectedTenant)
? parseFloat(selectedTenant)
: selectedTenant
: null
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-nested-docs",
"version": "3.20.0",
"version": "3.21.0",
"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.20.0",
"version": "3.21.0",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-search",
"version": "3.20.0",
"version": "3.21.0",
"description": "Search plugin for Payload",
"keywords": [
"payload",

View File

@@ -42,10 +42,25 @@ export type SearchPluginConfig = {
defaultPriorities?: {
[collection: string]: ((doc: any) => number | Promise<number>) | number
}
/**
* Controls whether drafts are deleted from the search index
*
* @default true
*/
deleteDrafts?: boolean
localize?: boolean
/**
* We use batching when re-indexing large collections. You can control the amount of items per batch, lower numbers should help with memory.
*
* @default 50
*/
reindexBatchSize?: number
searchOverrides?: { fields?: FieldsOverride } & Partial<Omit<CollectionConfig, 'fields'>>
/**
* Controls whether drafts are synced to the search index
*
* @default false
*/
syncDrafts?: boolean
}

View File

@@ -142,15 +142,42 @@ export const syncDocAsSearchIndex = async ({
}
}
if (deleteDrafts && status === 'draft') {
// do not include draft docs in search results, so delete the record
try {
await payload.delete({
id: searchDocID,
collection: searchSlug,
req,
})
} catch (err: unknown) {
payload.logger.error({ err, msg: `Error deleting ${searchSlug} document.` })
// Check to see if there's a published version of the doc
// We don't want to remove the search doc if there is a published version but a new draft has been created
const {
docs: [docWithPublish],
} = await payload.find({
collection,
draft: false,
locale: syncLocale,
req,
where: {
and: [
{
_status: {
equals: 'published',
},
},
{
id: {
equals: id,
},
},
],
},
})
if (!docWithPublish) {
// do not include draft docs in search results, so delete the record
try {
await payload.delete({
id: searchDocID,
collection: searchSlug,
req,
})
} catch (err: unknown) {
payload.logger.error({ err, msg: `Error deleting ${searchSlug} document.` })
}
}
}
} else if (doSync) {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-sentry",
"version": "3.20.0",
"version": "3.21.0",
"description": "Sentry plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-seo",
"version": "3.20.0",
"version": "3.21.0",
"description": "SEO plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-stripe",
"version": "3.20.0",
"version": "3.21.0",
"description": "Stripe plugin for Payload",
"keywords": [
"payload",

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.20.0",
"version": "3.21.0",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -29,6 +29,7 @@ export const ListJSXConverter: JSXConverters<SerializedListItemNode | Serialized
className={`list-item-checkbox${node.checked ? ' list-item-checkbox-checked' : ' list-item-checkbox-unchecked'}${hasSubLists ? ' nestedListItem' : ''}`}
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
role="checkbox"
style={{ listStyleType: 'none' }}
tabIndex={-1}
value={node?.value}
>
@@ -45,7 +46,11 @@ export const ListJSXConverter: JSXConverters<SerializedListItemNode | Serialized
)
} else {
return (
<li className={hasSubLists ? 'nestedListItem' : ''} value={node?.value}>
<li
className={`${hasSubLists ? 'nestedListItem' : ''}`}
style={hasSubLists ? { listStyleType: 'none' } : undefined}
value={node?.value}
>
{children}
</li>
)

View File

@@ -1,4 +0,0 @@
.payload-richtext .nestedListItem,
.payload-richtext .list-check {
list-style-type: none;
}

View File

@@ -11,7 +11,6 @@ import type { JSXConverters } from './converter/types.js'
import { defaultJSXConverters } from './converter/defaultConverters.js'
import { convertLexicalToJSX } from './converter/index.js'
import './index.css'
export type JSXConvertersFunction<
T extends { [key: string]: any; type?: string } =

View File

@@ -12,6 +12,7 @@ import {
Pill,
RenderFields,
SectionTitle,
useDocumentForm,
useDocumentInfo,
useEditDepth,
useFormSubmitted,
@@ -23,6 +24,7 @@ import { deepCopyObjectSimpleWithoutReactComponents, reduceFieldsToValues } from
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
const baseClass = 'lexical-block'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { getTranslation } from '@payloadcms/translations'
import { $getNodeByKey } from 'lexical'
@@ -33,9 +35,9 @@ import type { BlockFields } from '../../server/nodes/BlocksNode.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js'
import './index.scss'
import { $isBlockNode } from '../nodes/BlocksNode.js'
import { BlockContent } from './BlockContent.js'
import './index.scss'
import { removeEmptyArrayValues } from './removeEmptyArrayValues.js'
type Props = {
@@ -64,6 +66,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
},
uuid: uuidFromContext,
} = useEditorConfigContext()
const { fields: parentDocumentFields } = useDocumentForm()
const onChangeAbortControllerRef = useRef(new AbortController())
const editDepth = useEditDepth()
const [errorCount, setErrorCount] = React.useState(0)
@@ -127,7 +131,9 @@ export const BlockComponent: React.FC<Props> = (props) => {
data: formData,
docPermissions: { fields: true },
docPreferences: await getDocPreferences(),
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
globalSlug,
initialBlockData: formData,
operation: 'update',
renderAllFields: true,
schemaPath: schemaFieldsPath,
@@ -164,6 +170,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
collectionSlug,
globalSlug,
getDocPreferences,
parentDocumentFields,
])
const [isCollapsed, setIsCollapsed] = React.useState<boolean>(
@@ -174,7 +181,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
const clientSchemaMap = featureClientSchemaMap['blocks']
const blocksField: BlocksFieldClient | undefined = clientSchemaMap[
const blocksField: BlocksFieldClient | undefined = clientSchemaMap?.[
componentMapRenderedBlockPath
]?.[0] as BlocksFieldClient
@@ -196,8 +203,10 @@ export const BlockComponent: React.FC<Props> = (props) => {
fields: true,
},
docPreferences: await getDocPreferences(),
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
formState: prevFormState,
globalSlug,
initialBlockFormState: prevFormState,
operation: 'update',
renderAllFields: submit ? true : false,
schemaPath: schemaFieldsPath,
@@ -208,7 +217,9 @@ export const BlockComponent: React.FC<Props> = (props) => {
return prevFormState
}
newFormState.blockName = prevFormState.blockName
if (prevFormState.blockName) {
newFormState.blockName = prevFormState.blockName
}
const newFormStateData: BlockFields = reduceFieldsToValues(
removeEmptyArrayValues({
@@ -252,6 +263,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
globalSlug,
schemaFieldsPath,
formData.blockType,
parentDocumentFields,
editor,
nodeKey,
],
@@ -436,6 +448,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
],
)
const clientBlockFields = clientBlock?.fields ?? []
const BlockDrawer = useMemo(
() => () => (
<EditDepthProvider>
@@ -449,7 +463,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
{initialState ? (
<>
<RenderFields
fields={clientBlock?.fields}
fields={clientBlockFields}
forceRender
parentIndexPath=""
parentPath="" // See Blocks feature path for details as for why this is empty
@@ -488,7 +502,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
return await onChange({ formState, submit: true })
},
]}
fields={clientBlock?.fields}
fields={clientBlockFields}
initialState={initialState}
onChange={[onChange]}
onSubmit={(formState, newData) => {
@@ -512,7 +526,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
CustomBlock={CustomBlock}
EditButton={EditButton}
errorCount={errorCount}
formSchema={clientBlock?.fields}
formSchema={clientBlockFields}
initialState={initialState}
nodeKey={nodeKey}
RemoveButton={RemoveButton}
@@ -523,6 +537,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
BlockCollapsible,
BlockDrawer,
CustomBlock,
clientBlockFields,
RemoveButton,
EditButton,
editor,

View File

@@ -10,7 +10,7 @@ import type { FormState } from 'payload'
export function removeEmptyArrayValues({ fields }: { fields: FormState }): FormState {
for (const key in fields) {
const field = fields[key]
if (Array.isArray(field.rows) && 'value' in field) {
if (Array.isArray(field?.rows) && 'value' in field) {
field.disableFormData = true
}
}

View File

@@ -16,6 +16,7 @@ import {
FormSubmit,
RenderFields,
ShimmerEffect,
useDocumentForm,
useDocumentInfo,
useEditDepth,
useServerFunctions,
@@ -26,6 +27,7 @@ import { $getNodeByKey } from 'lexical'
import './index.scss'
import { deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared'
import { v4 as uuid } from 'uuid'
import type { InlineBlockFields } from '../../server/nodes/InlineBlocksNode.js'
@@ -77,6 +79,8 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
setCreatedInlineBlock,
uuid: uuidFromContext,
} = useEditorConfigContext()
const { fields: parentDocumentFields } = useDocumentForm()
const { getFormState } = useServerFunctions()
const editDepth = useEditDepth()
const firstTimeDrawer = useRef(false)
@@ -112,29 +116,25 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
const clientSchemaMap = featureClientSchemaMap['blocks']
const blocksField: BlocksFieldClient = clientSchemaMap[
const blocksField: BlocksFieldClient = clientSchemaMap?.[
componentMapRenderedBlockPath
]?.[0] as BlocksFieldClient
const clientBlock = blocksField?.blocks?.[0]
const clientBlockFields = clientBlock?.fields ?? []
// Open drawer on "mount"
useEffect(() => {
if (!firstTimeDrawer.current && createdInlineBlock?.getKey() === nodeKey) {
// > 2 because they always have "id" and "blockName" fields
if (clientBlock?.fields?.length > 2) {
if (clientBlockFields.length > 2) {
toggleDrawer()
}
setCreatedInlineBlock?.(undefined)
firstTimeDrawer.current = true
}
}, [
clientBlock?.fields?.length,
createdInlineBlock,
nodeKey,
setCreatedInlineBlock,
toggleDrawer,
])
}, [clientBlockFields.length, createdInlineBlock, nodeKey, setCreatedInlineBlock, toggleDrawer])
const removeInlineBlock = useCallback(() => {
editor.update(() => {
@@ -165,7 +165,10 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
data: formData,
docPermissions: { fields: true },
docPreferences: await getDocPreferences(),
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
globalSlug,
initialBlockData: formData,
initialBlockFormState: formData,
operation: 'update',
renderAllFields: true,
schemaPath: schemaFieldsPath,
@@ -195,6 +198,7 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
collectionSlug,
globalSlug,
getDocPreferences,
parentDocumentFields,
])
/**
@@ -214,8 +218,10 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
fields: true,
},
docPreferences: await getDocPreferences(),
documentFormState: deepCopyObjectSimpleWithoutReactComponents(parentDocumentFields),
formState: prevFormState,
globalSlug,
initialBlockFormState: prevFormState,
operation: 'update',
renderAllFields: submit ? true : false,
schemaPath: schemaFieldsPath,
@@ -233,7 +239,15 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
return state
},
[getFormState, id, collectionSlug, getDocPreferences, globalSlug, schemaFieldsPath],
[
getFormState,
id,
collectionSlug,
getDocPreferences,
parentDocumentFields,
globalSlug,
schemaFieldsPath,
],
)
// cleanup effect
useEffect(() => {

View File

@@ -25,13 +25,17 @@ export const BlocksFeatureClient = createClientFeature(
const schemaMapRenderedInlineBlockPathPrefix = `${schemaPath}.lexical_internal_feature.blocks.lexical_inline_blocks`
const clientSchema = featureClientSchemaMap['blocks']
if (!clientSchema) {
return {}
}
const blocksFields: BlocksFieldClient[] = Object.entries(clientSchema)
.filter(
([key]) =>
key.startsWith(schemaMapRenderedBlockPathPrefix + '.') &&
!key.replace(schemaMapRenderedBlockPathPrefix + '.', '').includes('.'),
)
.map(([key, value]) => value[0] as BlocksFieldClient)
.map(([, value]) => value[0] as BlocksFieldClient)
const inlineBlocksFields: BlocksFieldClient[] = Object.entries(clientSchema)
.filter(
@@ -39,15 +43,19 @@ export const BlocksFeatureClient = createClientFeature(
key.startsWith(schemaMapRenderedInlineBlockPathPrefix + '.') &&
!key.replace(schemaMapRenderedInlineBlockPathPrefix + '.', '').includes('.'),
)
.map(([key, value]) => value[0] as BlocksFieldClient)
.map(([, value]) => value[0] as BlocksFieldClient)
const clientBlocks: ClientBlock[] = blocksFields.map((field) => {
return field.blocks[0]
})
const clientBlocks: ClientBlock[] = blocksFields
.map((field) => {
return field.blocks[0]
})
.filter((block) => block !== undefined)
const clientInlineBlocks: ClientBlock[] = inlineBlocksFields.map((field) => {
return field.blocks[0]
})
const clientInlineBlocks: ClientBlock[] = inlineBlocksFields
.map((field) => {
return field.blocks[0]
})
.filter((block) => block !== undefined)
return {
nodes: [BlockNode, InlineBlockNode],

View File

@@ -92,7 +92,7 @@ export const getBlockMarkdownTransformers = ({
const childrenString = linesInBetween.join('\n').trim()
const propsString: null | string = openMatch?.length > 2 ? openMatch[2]?.trim() : null
const propsString = openMatch[2]?.trim()
const markdownToLexical = getMarkdownToLexical(allNodes, allTransformers)

View File

@@ -38,17 +38,17 @@ export function linesFromStartToContentAndPropsString({
let isSelfClosing = false
let isWithinCodeBlockAmount = 0
const beforeStartLine = linesCopy[0].slice(0, startMatch.index)
const beforeStartLine = linesCopy[0]!.slice(0, startMatch.index)
let endlineLastCharIndex = 0
let endLineIndex = startLineIndex
mainLoop: for (let lineIndex = 0; lineIndex < linesCopy.length; lineIndex++) {
const line = trimChildren ? linesCopy[lineIndex].trim() : linesCopy[lineIndex]
mainLoop: for (const [lineIndex, lineCopy] of linesCopy.entries()) {
const line = trimChildren ? lineCopy.trim() : lineCopy
let amountOfBeginningSpacesRemoved = 0
if (trimChildren) {
for (let i = 0; i < linesCopy[lineIndex].length; i++) {
if (linesCopy[lineIndex][i] === ' ') {
for (let i = 0; i < lineCopy.length; i++) {
if (lineCopy[i] === ' ') {
amountOfBeginningSpacesRemoved++
} else {
break
@@ -159,7 +159,7 @@ export function linesFromStartToContentAndPropsString({
}
}
const afterEndLine = linesCopy[endLineIndex].trim().slice(endlineLastCharIndex)
const afterEndLine = linesCopy[endLineIndex]!.trim().slice(endlineLastCharIndex)
return {
afterEndLine,

View File

@@ -361,18 +361,19 @@ function getMarkdownTransformerForBlock(
if (beforeStartLine?.length) {
prevNodes = markdownToLexical({ markdown: beforeStartLine })?.root?.children ?? []
if (prevNodes?.length) {
rootNode.append($parseSerializedNode(prevNodes[0]))
const firstPrevNode = prevNodes?.[0]
if (firstPrevNode) {
rootNode.append($parseSerializedNode(firstPrevNode))
}
}
rootNode.append(node)
if (afterEndLine?.length) {
nextNodes = markdownToLexical({ markdown: afterEndLine })?.root?.children ?? []
nextNodes = markdownToLexical({ markdown: afterEndLine })?.root?.children
const lastChild = rootNode.getChildren()[rootNode.getChildren().length - 1]
const children = ($parseSerializedNode(nextNodes[0]) as ElementNode)?.getChildren()
const children = ($parseSerializedNode(nextNodes[0]!) as ElementNode)?.getChildren()
if (children?.length) {
for (const child of children) {
;(lastChild as ElementNode).append(child)
@@ -408,7 +409,7 @@ function getMarkdownTransformerForBlock(
childrenString = linesInBetween.join('\n').trim()
}
const propsString: null | string = openMatch?.length > 1 ? openMatch[1]?.trim() : null
const propsString = openMatch[1]?.trim()
const markdownToLexical = getMarkdownToLexical(allNodes, allTransformers)

View File

@@ -13,7 +13,7 @@ export const blockValidationHOC = (
const blockFieldData = node.fields ?? ({} as BlockFields)
const {
options: { id, collectionSlug, operation, preferences, req },
options: { id, collectionSlug, data, operation, preferences, req },
} = validation
// find block
@@ -32,8 +32,10 @@ export const blockValidationHOC = (
id,
collectionSlug,
data: blockFieldData,
documentData: data,
fields: block.fields,
fieldSchemaMap: undefined,
initialBlockData: blockFieldData,
operation: operation === 'create' || operation === 'update' ? operation : 'update',
permissions: {},
preferences,
@@ -42,12 +44,15 @@ export const blockValidationHOC = (
schemaPath: '',
})
let errorPaths: string[] = []
const errorPathsSet = new Set<string>()
for (const fieldKey in result) {
if (result[fieldKey].errorPaths) {
errorPaths = errorPaths.concat(result[fieldKey].errorPaths)
if (result[fieldKey].errorPaths?.length) {
for (const errorPath of result[fieldKey].errorPaths) {
errorPathsSet.add(errorPath)
}
}
}
const errorPaths = Array.from(errorPathsSet)
if (errorPaths.length) {
return 'The following fields are invalid: ' + errorPaths.join(', ')

View File

@@ -210,13 +210,15 @@ function TableCellResizer({ editor }: { editor: LexicalEditor }): JSX.Element {
}
const getCellColumnIndex = (tableCellNode: TableCellNode, tableMap: TableMapType) => {
for (let row = 0; row < tableMap.length; row++) {
for (let column = 0; column < tableMap[row].length; column++) {
if (tableMap[row][column].cell === tableCellNode) {
return column
let columnIndex: number | undefined
tableMap.forEach((row) => {
row.forEach((cell, columnIndexInner) => {
if (cell.cell === tableCellNode) {
columnIndex = columnIndexInner
}
}
}
})
})
return columnIndex
}
const updateColumnWidth = useCallback(

View File

@@ -65,8 +65,12 @@ export const TableMarkdownTransformer: (props: {
},
regExp: TABLE_ROW_REG_EXP,
replace: (parentNode, _1, match) => {
const match0 = match[0]
if (!match0) {
return
}
// Header row
if (TABLE_ROW_DIVIDER_REG_EXP.test(match[0])) {
if (TABLE_ROW_DIVIDER_REG_EXP.test(match0)) {
const table = parentNode.getPreviousSibling()
if (!table || !$isTableNode(table)) {
return
@@ -91,7 +95,7 @@ export const TableMarkdownTransformer: (props: {
return
}
const matchCells = mapToTableCells(match[0], allTransformers)
const matchCells = mapToTableCells(match0, allTransformers)
if (matchCells == null) {
return
@@ -136,7 +140,7 @@ export const TableMarkdownTransformer: (props: {
table.append(tableRow)
for (let i = 0; i < maxCells; i++) {
tableRow.append(i < cells.length ? cells[i] : $createTableCell('', allTransformers))
tableRow.append(i < cells.length ? cells[i]! : $createTableCell('', allTransformers))
}
}

View File

@@ -28,7 +28,7 @@ export const MarkdownTransformer: (enabledHeadingSizes: HeadingTagType[]) => Ele
},
regExp,
replace: createBlockNode((match) => {
const tag = ('h' + match[1].length) as HeadingTagType
const tag = ('h' + match[1]?.length) as HeadingTagType
return $createHeadingNode(tag)
}),
}

View File

@@ -408,7 +408,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
linkParent = getSelectedNode(selection).getParent()
} else {
if (selectedNodes.length) {
linkParent = selectedNodes[0].getParent()
linkParent = selectedNodes[0]?.getParent() ?? null
}
}

View File

@@ -295,9 +295,9 @@ export function $toggleLink(payload: ({ fields: LinkFields } & LinkPayload) | nu
if ($isLinkNode(parent)) {
const children = parent.getChildren()
for (let i = 0; i < children.length; i += 1) {
parent.insertBefore(children[i])
}
children.forEach((child) => {
parent.insertBefore(child)
})
parent.remove()
}
@@ -307,7 +307,7 @@ export function $toggleLink(payload: ({ fields: LinkFields } & LinkPayload) | nu
}
// Add or merge LinkNodes
if (nodes?.length === 1) {
const firstNode = nodes[0]
const firstNode = nodes[0]!
// if the first node is a LinkNode or if its
// parent is a LinkNode, we update the URL, target and rel.
const linkNode: LinkNode | null = $isLinkNode(firstNode)
@@ -375,10 +375,7 @@ export function $toggleLink(payload: ({ fields: LinkFields } & LinkPayload) | nu
}
if (linkNode !== null) {
const children = node.getChildren()
for (let i = 0; i < children.length; i += 1) {
linkNode.append(children[i])
}
linkNode.append(...children)
}
node.remove()

View File

@@ -13,7 +13,7 @@ export const linkValidation = (
return async ({
node,
validation: {
options: { id, collectionSlug, operation, preferences, req },
options: { id, collectionSlug, data, operation, preferences, req },
},
}) => {
/**
@@ -24,8 +24,10 @@ export const linkValidation = (
id,
collectionSlug,
data: node.fields,
documentData: data,
fields: sanitizedFieldsWithoutText, // Sanitized in feature.server.ts
fieldSchemaMap: undefined,
initialBlockData: node.fields,
operation: operation === 'create' || operation === 'update' ? operation : 'update',
permissions: {},
preferences,
@@ -34,12 +36,15 @@ export const linkValidation = (
schemaPath: '',
})
let errorPaths: string[] = []
const errorPathsSet = new Set<string>()
for (const fieldKey in result) {
if (result[fieldKey].errorPaths) {
errorPaths = errorPaths.concat(result[fieldKey].errorPaths)
if (result[fieldKey].errorPaths?.length) {
for (const errorPath of result[fieldKey].errorPaths) {
errorPathsSet.add(errorPath)
}
}
}
const errorPaths = Array.from(errorPathsSet)
if (errorPaths.length) {
return 'The following fields are invalid: ' + errorPaths.join(', ')

View File

@@ -34,7 +34,7 @@ export const listReplace = (listType: ListType): ElementTransformer['replace'] =
}
listItem.append(...children)
listItem.select(0, 0)
const indent = Math.floor(match[1].length / LIST_INDENT_SIZE)
const indent = Math.floor(match[1]!.length / LIST_INDENT_SIZE)
if (indent) {
listItem.setIndent(indent)
}

View File

@@ -13,6 +13,7 @@ export const uploadValidation = (
validation: {
options: {
id,
data,
operation,
preferences,
req,
@@ -45,9 +46,12 @@ export const uploadValidation = (
const result = await fieldSchemasToFormState({
id,
collectionSlug: node.relationTo,
data: node?.fields ?? {},
documentData: data,
fields: collection.fields,
fieldSchemaMap: undefined,
initialBlockData: node?.fields ?? {},
operation: operation === 'create' || operation === 'update' ? operation : 'update',
permissions: {},
preferences,
@@ -56,12 +60,15 @@ export const uploadValidation = (
schemaPath: '',
})
let errorPaths: string[] = []
const errorPathsSet = new Set<string>()
for (const fieldKey in result) {
if (result[fieldKey].errorPaths) {
errorPaths = errorPaths.concat(result[fieldKey].errorPaths)
if (result[fieldKey].errorPaths?.length) {
for (const errorPath of result[fieldKey].errorPaths) {
errorPathsSet.add(errorPath)
}
}
}
const errorPaths = Array.from(errorPathsSet)
if (errorPaths.length) {
return 'The following fields are invalid: ' + errorPaths.join(', ')

View File

@@ -56,6 +56,7 @@ export const RscEntryLexicalField: React.FC<
id: args.id,
clientFieldSchemaMap: args.clientFieldSchemaMap,
collectionSlug: args.collectionSlug,
documentData: args.data,
field,
fieldSchemaMap: args.fieldSchemaMap,
lexicalFieldSchemaPath: schemaPath,

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