Compare commits

..

49 Commits

Author SHA1 Message Date
Elliot DeNolf
a80c6b5212 chore(release): v3.22.0 [skip ci] 2025-02-07 09:22:48 -05:00
Dan Ribbens
6f53747040 revert(ui): adds admin.components.listControlsMenu option (#11047)
Reverts payloadcms/payload#10981

In using this feature I think we need to iterate once more before it can
be released.
2025-02-07 09:15:46 -05:00
Jacob Fletcher
b820a75ec5 fix(ui): removing final condition closes where builder (#11032)
When filtering the list view, removing the final condition from the
query closes the "where" builder entirely. This forces the user to
re-open the filter controls and begin adding conditions from the start.
2025-02-07 09:15:18 -05:00
Germán Jabloñski
49d94d53e0 chore: pnpm dev defaults to the _community test suite (#11044)
- `pnpm dev` defaults to the _community test suite
- add a console log indicating which suite is running
2025-02-07 13:10:24 +00:00
Germán Jabloñski
feea444867 chore: find and use an available port in tests (#11043)
You can now run `pnpm dev [test-suite]` even if port 3000 is busy.

I copied the error message as is from what nextjs shows.
2025-02-07 09:45:06 -03:00
Alessio Gravili
257cad71ce chore: fix eslint wasn't running in test dir (#11036)
This PR fixes 2 eslint config issues that prevented it from running in our test dir

- spec files were ignored by the root eslint config. This should have only ignored spec files within our packages, as they are ignored by the respective package tsconfigs
- defining the payload plugin crashed eslint in our test dir, as it was already defined in the root eslint config it was inheriting
2025-02-07 03:54:26 +00:00
Alessio Gravili
04dad9d7a6 chore: fix flaky lexical test (#11035)
The "select decoratorNodes" test was flaky, as it often selected the relationship block node with a relationship to "payload.jpg", instead of the upload node for "payload.jpg", depending on which node loaded first.

This PR ensures it waits for all blocks to be loaded, and updates the selector to specifically target the upload node
2025-02-07 03:24:49 +00:00
Alessio Gravili
098fe10ade chore: deflake joins e2e tests (#11034)
Previously, data created by other tests was also leaking into unrelated tests, causing them to fail. The new reset-db-between-tests logic added by this PR fixes this. 

Additionally, this increases playwright timeouts for CI, and adds a specific timeout override for opening a drawer, as it was incredibly slow in CI
2025-02-07 02:38:38 +00: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
Tylan Davis
1a68fa14bb docs: correct broken NPM badge images on plugin documentation (#10959)
### What?
Fixes broken NPM badge images/links on plugin documentation pages.

### Why?
They were not properly formatted and did not work.

### How?
Corrects the formatting.

Before: https://payloadcms.com/docs/plugins/nested-docs
After:
https://payloadcms.com/docs/dynamic/plugins/nested-docs?branch=docs/npm-badges
2025-02-03 22:45:10 +00:00
Said Akhrarov
b33749905d test: admin list view custom components (#10956)
### What?
This PR adds tests for custom list view components to the existing suite
in `admin/e2e/list-view/`. Custom components are already tested in the
document-level counterpart, and should be tested here as well.

### Why?
Previously, there were no tests for these list view components.
Refactors, features, or changes that impact the importMap, default list
view, etc., could affect how these components get rendered. It's safer
to have tests in place to catch this as custom list view components, in
general, are used quite often.

### How?
This PR adds 5 simple tests that check for the rendering of the
following list view components:
- `BeforeList`
- `BeforeListTable`
- `UI Field Cell`
- `AfterList`
- `AfterListTable`
2025-02-03 16:18:26 -05:00
Jessica Chowdhury
0f85a6e0cc fix(plugin-search): generates full docURL with basePath from next config (#10910)
Fixes #10878. The Search Plugin displays a link within the search
results collection that points to the underlying document that is
related to that result. The href used, however, was not accounting for
any `basePath` provided to the `next.config.js`, leading to a 404 if
using a custom base path. The fix is to use the `Link` component from
`next/link` instead of an anchor tag directly. This will automatically
inject the the base path into the href before rendering it.

This PR also brings back the `CopyToClipboard` component. This makes it
easy for the user to copy the href instead of navigating to it.

---------

Co-authored-by: Jacob Fletcher <jacobsfletch@gmail.com>
2025-02-03 15:17:07 -05:00
Jacob Fletcher
177127141e chore(plugin-search): improves types (#10955)
There were a number of areas within the Search Plugin where typings
could have been improved, namely:
- The `customProps` sent to the `ReindexButton`. This now uses the
`satisfies` keyword to ensure type strictness.
- The `collectionLabels` prop sent to the `ReindexButtonClient`
component. This is now standardized behind a new
`ResolvedCollectionLabels` type to closely reflect `CollectionLabels`.
This was also converted from unnecessarily invoking a function to being
a basic object.
- The `locale` type sent through `SyncDocArgs`. This now uses
`Locale['code']` from Payload.
2025-02-03 19:53:04 +00:00
Steve Kuznetsov
0a1cc6adcb templates: use typed functions in website template seed endpoint (#10420)
`JSON.parse(JSON.stringify().replace())` is easy to make mistakes with and since we have TypeScript data objects already for the data we're seeding it's pretty easy to just factor these as functions, making their dependencies explicit.
2025-02-03 12:40:22 -07:00
Jacob Fletcher
4a4e90a170 chore(plugin-search): deprecates apiBasePath from config (#10953)
Continuation of #10632. The `apiBasePath` property in the Search Plugin
config is unnecessary. This plugin reads directly from the Payload
config for this property. Exposing it to the plugin's config was likely
a mistake during sanitization before passing it through to the remaining
files. This property was added to resolve the types, but as result,
exposed it to the config unnecessarily. This PR marks this property with
the deprecated flag to prevent breaking changes.
2025-02-03 19:06:05 +00:00
Alessio Gravili
136c90c725 fix(richtext-lexical): link drawer has no fields if parent document create access control is false (#10954)
Previously, the lexical link drawer did not display any fields if the
`create` permission was false, even though the `update` permission was
true.

The issue was a faulty permission check in `RenderFields` that did not
check the top-level permission operation keys for truthiness. It only
checked if the `permissions` variable itself was `true`, or if the
sub-fields had `create` / `update` permissions set to `true`.
2025-02-03 19:02:40 +00:00
Suphon T.
6353cf8bbe fix(plugin-search): gets api route from useConfig (#10632)
This fixes #10631.

Originally the api basepath for the reindex button is resolved during
plugin initialization. Looks like this happens before payload overrides
the config with the `basePath `from the next config.
I've changed it so that it uses the `useConfig` hook, and manually
tested that it works.

![CleanShot 2568-01-17 at 16 03
16@2x](https://github.com/user-attachments/assets/c931577b-2717-4635-b5c6-17aa1b4eb734)
2025-02-03 18:14:21 +00:00
Alessio Gravili
109de8cdb3 chore(deps): bump packages used to build payload (#10950)
Bumps all babel/esbuild/swc/react compiler packages
2025-02-03 16:53:42 +00:00
271 changed files with 7181 additions and 6697 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

@@ -18,7 +18,7 @@
},
"devDependencies": {
"@octokit/webhooks-types": "^7.5.1",
"@swc/jest": "^0.2.36",
"@swc/jest": "^0.2.37",
"@types/jest": "^27.5.2",
"@types/node": "^20.16.5",
"@typescript-eslint/eslint-plugin": "^4.33.0",

114
.github/pnpm-lock.yaml generated vendored
View File

@@ -19,8 +19,8 @@ importers:
specifier: ^7.5.1
version: 7.5.1
'@swc/jest':
specifier: ^0.2.36
version: 0.2.36(@swc/core@1.7.26)
specifier: ^0.2.37
version: 0.2.37(@swc/core@1.7.26)
'@types/jest':
specifier: ^27.5.2
version: 27.5.2
@@ -48,9 +48,6 @@ importers:
prettier:
specifier: ^3.3.3
version: 3.3.3
ts-jest:
specifier: ^26.5.6
version: 26.5.6(jest@29.7.0(@types/node@20.16.5))(typescript@4.9.5)
typescript:
specifier: ^4.9.5
version: 4.9.5
@@ -68,8 +65,8 @@ importers:
specifier: ^7.5.1
version: 7.5.1
'@swc/jest':
specifier: ^0.2.36
version: 0.2.36(@swc/core@1.7.26)
specifier: ^0.2.37
version: 0.2.37(@swc/core@1.7.26)
'@types/jest':
specifier: ^27.5.2
version: 27.5.2
@@ -97,9 +94,6 @@ importers:
prettier:
specifier: ^3.3.3
version: 3.3.3
ts-jest:
specifier: ^26.5.6
version: 26.5.6(jest@29.7.0(@types/node@20.16.5))(typescript@4.9.5)
typescript:
specifier: ^4.9.5
version: 4.9.5
@@ -386,10 +380,6 @@ packages:
resolution: {integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
'@jest/types@26.6.2':
resolution: {integrity: sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==}
engines: {node: '>= 10.14.2'}
'@jest/types@29.6.3':
resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -542,8 +532,8 @@ packages:
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/jest@0.2.36':
resolution: {integrity: sha512-8X80dp81ugxs4a11z1ka43FPhP+/e+mJNXJSxiNYk8gIX/jPBtY4gQTrKu/KIoco8bzKuPI5lUxjfLiGsfvnlw==}
'@swc/jest@0.2.37':
resolution: {integrity: sha512-CR2BHhmXKGxTiFr21DYPRHQunLkX3mNIFGFkxBGji6r9uyIR5zftTOVYj1e0sFNMV2H7mf/+vpaglqaryBtqfQ==}
engines: {npm: '>= 7.0.0'}
peerDependencies:
'@swc/core': '*'
@@ -590,9 +580,6 @@ packages:
'@types/yargs-parser@21.0.3':
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
'@types/yargs@15.0.19':
resolution: {integrity: sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==}
'@types/yargs@17.0.33':
resolution: {integrity: sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==}
@@ -746,10 +733,6 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
bs-logger@0.2.6:
resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==}
engines: {node: '>= 6'}
bser@2.1.1:
resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==}
@@ -783,9 +766,6 @@ packages:
resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==}
engines: {node: '>=10'}
ci-info@2.0.0:
resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==}
ci-info@3.9.0:
resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==}
engines: {node: '>=8'}
@@ -1133,10 +1113,6 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-ci@2.0.0:
resolution: {integrity: sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==}
hasBin: true
is-core-module@2.15.1:
resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==}
engines: {node: '>= 0.4'}
@@ -1311,10 +1287,6 @@ packages:
resolution: {integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
jest-util@26.6.2:
resolution: {integrity: sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==}
engines: {node: '>= 10.14.2'}
jest-util@29.7.0:
resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -1414,9 +1386,6 @@ packages:
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
engines: {node: '>=10'}
make-error@1.3.6:
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
@@ -1438,11 +1407,6 @@ packages:
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
mkdirp@1.0.4:
resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
engines: {node: '>=10'}
hasBin: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1752,14 +1716,6 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-jest@26.5.6:
resolution: {integrity: sha512-rua+rCP8DxpA8b4DQD/6X2HQS8Zy/xzViVYfEs2OQu68tkCuKLV0Md8pmX55+W24uRIyAsf/BajRfxOs+R2MKA==}
engines: {node: '>= 10'}
hasBin: true
peerDependencies:
jest: '>=26 <27'
typescript: '>=3.8 <5.0'
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
@@ -1863,10 +1819,6 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -2307,14 +2259,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@jest/types@26.6.2':
dependencies:
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 20.16.5
'@types/yargs': 15.0.19
chalk: 4.1.2
'@jest/types@29.6.3':
dependencies:
'@jest/schemas': 29.6.3
@@ -2477,7 +2421,7 @@ snapshots:
'@swc/counter@0.1.3': {}
'@swc/jest@0.2.36(@swc/core@1.7.26)':
'@swc/jest@0.2.37(@swc/core@1.7.26)':
dependencies:
'@jest/create-cache-key-function': 29.7.0
'@swc/core': 1.7.26
@@ -2538,10 +2482,6 @@ snapshots:
'@types/yargs-parser@21.0.3': {}
'@types/yargs@15.0.19':
dependencies:
'@types/yargs-parser': 21.0.3
'@types/yargs@17.0.33':
dependencies:
'@types/yargs-parser': 21.0.3
@@ -2742,10 +2682,6 @@ snapshots:
node-releases: 2.0.18
update-browserslist-db: 1.1.0(browserslist@4.23.3)
bs-logger@0.2.6:
dependencies:
fast-json-stable-stringify: 2.1.0
bser@2.1.1:
dependencies:
node-int64: 0.4.0
@@ -2773,8 +2709,6 @@ snapshots:
char-regex@1.0.2: {}
ci-info@2.0.0: {}
ci-info@3.9.0: {}
cjs-module-lexer@1.4.1: {}
@@ -3127,10 +3061,6 @@ snapshots:
is-arrayish@0.2.1: {}
is-ci@2.0.0:
dependencies:
ci-info: 2.0.0
is-core-module@2.15.1:
dependencies:
hasown: 2.0.2
@@ -3470,15 +3400,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
jest-util@26.6.2:
dependencies:
'@jest/types': 26.6.2
'@types/node': 20.16.5
chalk: 4.1.2
graceful-fs: 4.2.11
is-ci: 2.0.0
micromatch: 4.0.8
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
@@ -3583,8 +3504,6 @@ snapshots:
dependencies:
semver: 7.6.3
make-error@1.3.6: {}
makeerror@1.0.12:
dependencies:
tmpl: 1.0.5
@@ -3604,8 +3523,6 @@ snapshots:
dependencies:
brace-expansion: 1.1.11
mkdirp@1.0.4: {}
ms@2.1.3: {}
natural-compare@1.4.0: {}
@@ -3859,21 +3776,6 @@ snapshots:
tree-kill@1.2.2: {}
ts-jest@26.5.6(jest@29.7.0(@types/node@20.16.5))(typescript@4.9.5):
dependencies:
bs-logger: 0.2.6
buffer-from: 1.1.2
fast-json-stable-stringify: 2.1.0
jest: 29.7.0(@types/node@20.16.5)
jest-util: 26.6.2
json5: 2.2.3
lodash: 4.17.21
make-error: 1.3.6
mkdirp: 1.0.4
semver: 7.6.3
typescript: 4.9.5
yargs-parser: 20.2.9
tslib@1.14.1: {}
tslib@2.7.0: {}
@@ -3959,8 +3861,6 @@ snapshots:
yallist@3.1.1: {}
yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:

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

@@ -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

@@ -6,7 +6,7 @@ desc: Easily build and manage forms from the Admin Panel. Send dynamic, personal
keywords: plugins, plugin, form, forms, form builder
---
[![npm](https://img.shields.io/npm/v/@payloadcms/plugin-form-builder)](https://www.npmjs.com/package/@payloadcms/plugin-form-builder)
![https://www.npmjs.com/package/@payloadcms/plugin-form-builder](https://img.shields.io/npm/v/@payloadcms/plugin-form-builder)
This plugin allows you to build and manage custom forms directly within the [Admin Panel](../admin/overview). Instead of hard-coding a new form into your website or application every time you need one, admins can simply define the schema for each form they need on-the-fly, and your front-end can map over this schema, render its own UI components, and match your brand's design system.

View File

@@ -6,7 +6,7 @@ desc: Scaffolds multi-tenancy for your Payload application
keywords: plugins, multi-tenant, multi-tenancy, plugin, payload, cms, seo, indexing, search, search engine
---
[![npm](https://img.shields.io/npm/v/@payloadcms/plugin-multi-tenant)](https://www.npmjs.com/package/@payloadcms/plugin-multi-tenant)
![https://www.npmjs.com/package/@payloadcms/plugin-multi-tenant](https://img.shields.io/npm/v/@payloadcms/plugin-multi-tenant)
This plugin sets up multi-tenancy for your application from within your [Admin Panel](../admin/overview). It does so by adding a `tenant` field to all specified collections. Your front-end application can then query data by tenant. You must add the Tenants collection so you control what fields are available for each tenant.
@@ -32,7 +32,7 @@ This plugin sets up multi-tenancy for your application from within your [Admin P
<Banner type="error">
**Warning**
By default this plugin cleans up documents when a tenant is deleted. You should ensure you have
By default this plugin cleans up documents when a tenant is deleted. You should ensure you have
strong access control on your tenants collection to prevent deletions by unauthorized users.
You can disabled this behavior by setting `cleanupAfterTenantDelete` to `false` in the plugin options.

View File

@@ -6,7 +6,7 @@ desc: Nested documents in a parent, child, and sibling relationship.
keywords: plugins, nested, documents, parent, child, sibling, relationship
---
[![npm](https://img.shields.io/npm/v/@payloadcms/plugin-nested-docs)](https://www.npmjs.com/package/@payloadcms/plugin-nested-docs)
![https://www.npmjs.com/package/@payloadcms/plugin-nested-docs](https://img.shields.io/npm/v/@payloadcms/plugin-nested-docs)
This plugin allows you to easily nest the documents of your application inside of one another. It does so by adding a
new `parent` field onto each of your documents that, when selected, attaches itself to the parent's tree. When you edit

View File

@@ -6,7 +6,7 @@ desc: Automatically create redirects for your Payload application
keywords: plugins, redirects, redirect, plugin, payload, cms, seo, indexing, search, search engine
---
[![npm](https://img.shields.io/npm/v/@payloadcms/plugin-redirects)](https://www.npmjs.com/package/@payloadcms/plugin-redirects)
![https://www.npmjs.com/package/@payloadcms/plugin-redirects](https://img.shields.io/npm/v/@payloadcms/plugin-redirects)
This plugin allows you to easily manage redirects for your application from within your [Admin Panel](../admin/overview). It does so by adding a `redirects` collection to your config that allows you specify a redirect from one URL to another. Your front-end application can use this data to automatically redirect users to the correct page using proper HTTP status codes. This is useful for SEO, indexing, and search engine ranking when re-platforming or when changing your URL structure.

View File

@@ -6,7 +6,7 @@ desc: Generates records of your documents that are extremely fast to search on.
keywords: plugins, search, search plugin, search engine, search index, search results, search bar, search box, search field, search form, search input
---
[![npm](https://img.shields.io/npm/v/@payloadcms/plugin-search)](https://www.npmjs.com/package/@payloadcms/plugin-search)
![https://www.npmjs.com/package/@payloadcms/plugin-search](https://img.shields.io/npm/v/@payloadcms/plugin-search)
This plugin generates records of your documents that are extremely fast to search on. It does so by creating a new `search` collection that is indexed in the database then saving a static copy of each of your documents using only search-critical data. Search records are automatically created, synced, and deleted behind-the-scenes as you manage your application's documents.

View File

@@ -6,7 +6,7 @@ desc: Integrate Sentry error tracking into your Payload application
keywords: plugins, sentry, error, tracking, monitoring, logging, bug, reporting, performance
---
[![npm](https://img.shields.io/npm/v/@payloadcms/plugin-sentry)](https://www.npmjs.com/package/@payloadcms/plugin-sentry)
![https://www.npmjs.com/package/@payloadcms/plugin-sentry](https://img.shields.io/npm/v/@payloadcms/plugin-sentry)
This plugin allows you to integrate [Sentry](https://sentry.io/) seamlessly with your [Payload](https://github.com/payloadcms/payload) application.

View File

@@ -6,7 +6,7 @@ desc: Easily accept payments with Stripe
keywords: plugins, stripe, payments, ecommerce
---
[![npm](https://img.shields.io/npm/v/@payloadcms/plugin-stripe)](https://www.npmjs.com/package/@payloadcms/plugin-stripe)
![https://www.npmjs.com/package/@payloadcms/plugin-stripe](https://img.shields.io/npm/v/@payloadcms/plugin-stripe)
With this plugin you can easily integrate [Stripe](https://stripe.com) into Payload. Simply provide your Stripe credentials and this plugin will open up a two-way communication channel between the two platforms. This enables you to easily sync data back and forth, as well as proxy the Stripe REST API through Payload's [Access Control](../access-control/overview). Use this plugin to completely offload billing to Stripe and retain full control over your application's data.

View File

@@ -19,7 +19,7 @@ export const defaultESLintIgnores = [
'**/build/',
'**/node_modules/',
'**/temp/',
'**/*.spec.ts',
'**/packages/*.spec.ts',
'next-env.d.ts',
'**/app',
]

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.22.0",
"private": true,
"type": "module",
"scripts": {
@@ -126,7 +126,7 @@
"@sentry/nextjs": "^8.33.1",
"@sentry/node": "^8.33.1",
"@swc-node/register": "1.10.9",
"@swc/cli": "0.5.1",
"@swc/cli": "0.6.0",
"@swc/jest": "0.2.37",
"@types/fs-extra": "^11.0.2",
"@types/jest": "29.5.12",
@@ -166,7 +166,7 @@
"shelljs": "0.8.5",
"slash": "3.0.0",
"sort-package-json": "^2.10.0",
"swc-plugin-transform-remove-imports": "2.0.0",
"swc-plugin-transform-remove-imports": "3.1.0",
"tempy": "1.0.1",
"tstyche": "^3.1.1",
"tsx": "4.19.2",

View File

@@ -1,6 +1,6 @@
{
"name": "create-payload-app",
"version": "3.20.0",
"version": "3.22.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -60,7 +60,7 @@
"dependencies": {
"@clack/prompts": "^0.7.0",
"@sindresorhus/slugify": "^1.1.0",
"@swc/core": "1.7.10",
"@swc/core": "1.10.12",
"arg": "^5.0.0",
"chalk": "^4.1.0",
"comment-json": "^4.2.3",

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.22.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.22.0",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -88,7 +88,7 @@
"@hyrious/esbuild-plugin-commonjs": "^0.2.4",
"@payloadcms/eslint-config": "workspace:*",
"@types/to-snake-case": "1.0.0",
"esbuild": "0.24.0",
"esbuild": "0.24.2",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.20.0",
"version": "3.22.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.22.0",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -89,7 +89,7 @@
"@payloadcms/eslint-config": "workspace:*",
"@types/pg": "8.10.2",
"@types/to-snake-case": "1.0.0",
"esbuild": "0.24.0",
"esbuild": "0.24.2",
"payload": "workspace:*"
},
"peerDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/drizzle",
"version": "3.20.0",
"version": "3.22.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.22.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.22.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.22.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.22.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.22.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.22.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.22.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",
@@ -100,10 +100,10 @@
"uuid": "10.0.0"
},
"devDependencies": {
"@babel/cli": "7.25.9",
"@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.25.9",
"@babel/cli": "7.26.4",
"@babel/core": "7.26.7",
"@babel/preset-env": "7.26.7",
"@babel/preset-react": "7.26.3",
"@babel/preset-typescript": "7.26.0",
"@next/eslint-plugin-next": "15.1.5",
"@payloadcms/eslint-config": "workspace:*",
@@ -111,12 +111,12 @@
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"@types/uuid": "10.0.0",
"babel-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"esbuild": "0.24.0",
"babel-plugin-react-compiler": "19.0.0-beta-714736e-20250131",
"esbuild": "0.24.2",
"esbuild-sass-plugin": "3.3.1",
"eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-compiler": "19.0.0-beta-714736e-20250131",
"payload": "workspace:*",
"swc-plugin-transform-remove-imports": "2.0.0"
"swc-plugin-transform-remove-imports": "3.1.0"
},
"peerDependencies": {
"graphql": "^16.8.1",

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

@@ -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.22.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.22.0",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",
@@ -126,7 +126,7 @@
"@types/ws": "^8.5.10",
"copyfiles": "2.4.1",
"cross-env": "7.0.3",
"esbuild": "0.24.0",
"esbuild": "0.24.2",
"graphql-http": "^1.22.0",
"react-datepicker": "7.6.0",
"rimraf": "6.0.1",

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

@@ -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.22.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.22.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.22.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.22.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.22.0",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

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

View File

@@ -1,6 +1,7 @@
import type { CollectionConfig, Field } from 'payload'
import type { SearchPluginConfigWithLocales } from '../types.js'
import type { ReindexButtonServerProps } from './ui/ReindexButton/types.js'
import { generateReindexHandler } from '../utilities/generateReindexHandler.js'
@@ -8,7 +9,6 @@ import { generateReindexHandler } from '../utilities/generateReindexHandler.js'
export const generateSearchCollection = (
pluginConfig: SearchPluginConfigWithLocales,
): CollectionConfig => {
const apiBasePath = pluginConfig?.apiBasePath || '/api'
const searchSlug = pluginConfig?.searchOverrides?.slug || 'search'
const searchCollections = pluginConfig?.collections || []
const collectionLabels = pluginConfig?.labels
@@ -71,11 +71,10 @@ export const generateSearchCollection = (
{
path: '@payloadcms/plugin-search/client#ReindexButton',
serverProps: {
apiBasePath,
collectionLabels,
searchCollections,
searchSlug,
},
} satisfies ReindexButtonServerProps,
},
],
},

View File

@@ -1,10 +1,11 @@
'use client'
import { useConfig, useField } from '@payloadcms/ui'
import { CopyToClipboard, useConfig, useField } from '@payloadcms/ui'
import { formatAdminURL } from '@payloadcms/ui/shared'
import LinkImport from 'next/link.js'
import React from 'react'
// TODO: fix this import to work in dev mode within the monorepo in a way that is backwards compatible with 1.x
// import CopyToClipboard from 'payload/dist/admin/components/elements/CopyToClipboard'
const Link = (LinkImport.default || LinkImport) as unknown as typeof LinkImport.default
export const LinkToDocClient: React.FC = () => {
const { config } = useConfig()
@@ -27,6 +28,8 @@ export const LinkToDocClient: React.FC = () => {
path: `/collections/${value.relationTo || ''}/${value.value || ''}`,
})}`
const hrefToDisplay = `${process.env.NEXT_BASE_PATH || ''}${href}`
return (
<div style={{ marginBottom: 'var(--spacing-field, 1rem)' }}>
<div>
@@ -38,7 +41,7 @@ export const LinkToDocClient: React.FC = () => {
>
Doc URL
</span>
{/* <CopyToClipboard value={href} /> */}
<CopyToClipboard value={hrefToDisplay} />
</div>
<div
style={{
@@ -47,9 +50,9 @@ export const LinkToDocClient: React.FC = () => {
textOverflow: 'ellipsis',
}}
>
<a href={href} target="_blank">
{href}
</a>
<Link href={href} passHref {...{ rel: 'noopener noreferrer', target: '_blank' }}>
{hrefToDisplay}
</Link>
</div>
</div>
)

View File

@@ -5,6 +5,7 @@ import {
Popup,
PopupList,
toast,
useConfig,
useLocale,
useModal,
useTranslation,
@@ -20,15 +21,18 @@ import { ReindexConfirmModal } from './ReindexConfirmModal/index.js'
const confirmReindexModalSlug = 'confirm-reindex-modal'
export const ReindexButtonClient: React.FC<ReindexButtonProps> = ({
apiBasePath,
collectionLabels,
searchCollections,
searchSlug,
}) => {
const { closeModal, openModal } = useModal()
const { config } = useConfig()
const {
i18n: { t },
} = useTranslation()
const locale = useLocale()
const router = useRouter()
@@ -46,15 +50,16 @@ export const ReindexButtonClient: React.FC<ReindexButtonProps> = ({
closeConfirmModal()
setLoading(true)
const basePath = apiBasePath.endsWith('/') ? apiBasePath.slice(0, -1) : apiBasePath
try {
const endpointRes = await fetch(`${basePath}/${searchSlug}/reindex?locale=${locale.code}`, {
body: JSON.stringify({
collections: reindexCollections,
}),
method: 'POST',
})
const endpointRes = await fetch(
`${config.routes.api}/${searchSlug}/reindex?locale=${locale.code}`,
{
body: JSON.stringify({
collections: reindexCollections,
}),
method: 'POST',
},
)
const { message } = (await endpointRes.json()) as { message: string }
@@ -64,13 +69,13 @@ export const ReindexButtonClient: React.FC<ReindexButtonProps> = ({
toast.success(message)
router.refresh()
}
} catch (err: unknown) {
} catch (_err: unknown) {
// swallow error, toast shown above
} finally {
setReindexCollections([])
setLoading(false)
}
}, [closeConfirmModal, isLoading, reindexCollections, router, searchSlug, locale, apiBasePath])
}, [closeConfirmModal, isLoading, reindexCollections, router, searchSlug, locale, config])
const handleShowConfirmModal = useCallback(
(collections: string | string[] = searchCollections) => {

View File

@@ -1,33 +1,31 @@
import type { ResolvedCollectionLabels } from '../../../types.js'
import type { SearchReindexButtonServerComponent } from './types.js'
import { ReindexButtonClient } from './index.client.js'
export const ReindexButton: SearchReindexButtonServerComponent = (props) => {
const { apiBasePath, collectionLabels, i18n, searchCollections, searchSlug } = props
const { collectionLabels, i18n, searchCollections, searchSlug } = props
const getStaticLocalizedPluralLabels = () => {
return Object.fromEntries(
searchCollections.map((collection) => {
const labels = collectionLabels[collection]
const pluralLabel = labels?.plural
const resolvedCollectionLabels: ResolvedCollectionLabels = Object.fromEntries(
searchCollections.map((collection) => {
const labels = collectionLabels[collection]
const pluralLabel = labels?.plural
if (typeof pluralLabel === 'function') {
return [collection, pluralLabel({ t: i18n.t })]
}
if (typeof pluralLabel === 'function') {
return [collection, pluralLabel({ t: i18n.t })]
}
if (pluralLabel) {
return [collection, pluralLabel]
}
if (pluralLabel) {
return [collection, pluralLabel]
}
return [collection, collection]
}),
)
}
return [collection, collection]
}),
)
return (
<ReindexButtonClient
apiBasePath={apiBasePath}
collectionLabels={getStaticLocalizedPluralLabels()}
collectionLabels={resolvedCollectionLabels}
searchCollections={searchCollections}
searchSlug={searchSlug}
/>

View File

@@ -1,19 +1,19 @@
import type { CustomComponent, PayloadServerReactComponent, StaticLabel } from 'payload'
import type { CustomComponent, PayloadServerReactComponent } from 'payload'
import type { CollectionLabels } from '../../../types.js'
import type { CollectionLabels, ResolvedCollectionLabels } from '../../../types.js'
export type ReindexButtonProps = {
apiBasePath: string
collectionLabels: Record<string, StaticLabel>
collectionLabels: ResolvedCollectionLabels
searchCollections: string[]
searchSlug: string
}
type ReindexButtonServerProps = {
export type ReindexButtonServerProps = {
collectionLabels: CollectionLabels
} & ReindexButtonProps
} & Omit<ReindexButtonProps, 'collectionLabels'>
export type SearchReindexButtonClientComponent = ReindexButtonProps
export type SearchReindexButtonServerComponent = PayloadServerReactComponent<
CustomComponent<ReindexButtonServerProps>
>

View File

@@ -37,7 +37,6 @@ export const searchPlugin =
const pluginConfig: SearchPluginConfigWithLocales = {
// write any config defaults here
apiBasePath: config.routes?.api,
deleteDrafts: true,
labels,
locales,

View File

@@ -3,7 +3,6 @@ import type {
CollectionAfterDeleteHook,
CollectionConfig,
Field,
LabelFunction,
Locale,
Payload,
PayloadRequest,
@@ -31,24 +30,46 @@ export type BeforeSync = (args: {
export type FieldsOverride = (args: { defaultFields: Field[] }) => Field[]
export type SearchPluginConfig = {
/**
* @deprecated
* This plugin gets the api route from the config directly and does not need to be passed in.
* As long as you have `routes.api` set in your Payload config, the plugin will use that.
* This property will be removed in the next major version.
*/
apiBasePath?: string
beforeSync?: BeforeSync
collections?: string[]
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
}
export type CollectionLabels = {
[collection: string]: {
plural?: LabelFunction | StaticLabel
singular?: LabelFunction | StaticLabel
}
[collection: string]: CollectionConfig['labels']
}
export type ResolvedCollectionLabels = {
[collection: string]: StaticLabel
}
export type SearchPluginConfigWithLocales = {
@@ -62,7 +83,7 @@ export type SyncWithSearchArgs = {
} & Omit<Parameters<CollectionAfterChangeHook>[0], 'collection'>
export type SyncDocArgs = {
locale?: string
locale?: Locale['code']
onSyncError?: () => void
} & Omit<SyncWithSearchArgs, 'context' | 'previousDoc'>

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.22.0",
"description": "Sentry plugin for Payload",
"keywords": [
"payload",

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.20.0",
"version": "3.22.0",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -372,10 +372,10 @@
"uuid": "10.0.0"
},
"devDependencies": {
"@babel/cli": "7.25.9",
"@babel/core": "7.26.0",
"@babel/preset-env": "7.26.0",
"@babel/preset-react": "7.25.9",
"@babel/cli": "7.26.4",
"@babel/core": "7.26.7",
"@babel/preset-env": "7.26.7",
"@babel/preset-react": "7.26.3",
"@babel/preset-typescript": "7.26.0",
"@lexical/eslint-plugin": "0.21.0",
"@payloadcms/eslint-config": "workspace:*",
@@ -384,13 +384,13 @@
"@types/node": "22.5.4",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.1",
"babel-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"babel-plugin-react-compiler": "19.0.0-beta-714736e-20250131",
"babel-plugin-transform-remove-imports": "^1.8.0",
"esbuild": "0.24.0",
"esbuild": "0.24.2",
"esbuild-sass-plugin": "3.3.1",
"eslint-plugin-react-compiler": "19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-compiler": "19.0.0-beta-714736e-20250131",
"payload": "workspace:*",
"swc-plugin-transform-remove-imports": "2.0.0"
"swc-plugin-transform-remove-imports": "3.1.0"
},
"peerDependencies": {
"@faceless-ui/modal": "3.0.0-beta.2",

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)

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