Compare commits

..

76 Commits

Author SHA1 Message Date
Sasha
85b7a4d7be postgres identical blocks 2025-05-15 13:52:49 +03:00
Sasha
1f3ccb82d9 docs: update custom endpoints docs, handler does not accept array of functions anymore (#11110)
Fixes https://github.com/payloadcms/payload/issues/11109

Rewrites the description for the `handler` property of the `Endpoint`
type. This function:
* does not have `res` and `next` anymore
* the `handler` property does not accept an array of functions anymore.

Additionally, adds a more meaningful description for the `req` argument.
2025-02-11 13:56:34 +02:00
Said Akhrarov
d6a03eeaba docs: adds options table to payload-wide upload options (#10904)
<!--

Thank you for the PR! Please go through the checklist below and make
sure you've completed all the steps.

Please review the
[CONTRIBUTING.md](https://github.com/payloadcms/payload/blob/main/CONTRIBUTING.md)
document in this repository if you haven't already.

The following items will ensure that your PR is handled as smoothly as
possible:

- PR Title must follow conventional commits format. For example, `feat:
my new feature`, `fix(plugin-seo): my fix`.
- Minimal description explained as if explained to someone not
immediately familiar with the code.
- Provide before/after screenshots or code diffs if applicable.
- Link any related issues/discussions from GitHub or Discord.
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Fixes #

-->
### What?
This PR adds a table to the [Payload-wide Upload
Options](https://payloadcms.com/docs/upload/overview#payload-wide-upload-options)
section of the docs.

### Why?
To give users more insight into the customization options provided
out-of-the-box with uploads. Previously, these options were not visible
on the docs, forcing users to inspect source code to see how they can
customize their global upload settings. It wasn't clear, for example,
that a `fileSize` limit would not produce a 413 in a response by
default, but would truncate the file contents instead.

### How?
Changes to `docs/upload/overview.mdx`.
2025-02-11 01:19:58 +00:00
Jonathan Bredo
3f80c5993c docs: fixing 3 dead internal links (#11100)
### What?
Dead links located in docs, replaced with functioning links.

### Why?
It broke my [Cursor AI](https://github.com/getcursor/cursor) from
crawling and indexing the docs :(

### How?
Identified broken links by a free online service,
https://www.deadlinkchecker.com/, and fixed all links prefixed with
`https://payloadcms.com/docs`

[Referenced in
Discord.](https://discord.com/channels/967097582721572934/967097582721572937/1338664792717525032)
2025-02-11 01:09:11 +00:00
Paul
c18c58e1fb feat(ui): add timezone support to scheduled publish (#11090)
This PR extends timezone support to scheduled publish UI and collection,
the timezone will be stored on the `input` JSON instead of the
`waitUntil` date field so that we avoid needing a schema migration for
SQL databases.


![image](https://github.com/user-attachments/assets/0cc6522b-1b2f-4608-a592-67e3cdcdb566)

If a timezone is selected then the displayed date in the table will be
formatted for that timezone.

Timezones remain optional here as they can be deselected in which case
the date will behave as normal, rendering and formatting to the user's
local timezone.

For the backend logic that can be left untouched since the underlying
date values are stored in UTC the job runners will always handle this
relative time by default.

Todo:
- [x] add e2e to this drawer too to ensure that dates are rendered as
expected
2025-02-10 19:48:52 -05:00
Paul
36168184b5 fix(ui): incorrectly incrementing version counts if maxPerDoc is set to 0 (#11097)
Fixes https://github.com/payloadcms/payload/issues/9891

We were incorrectly setting max version count to 0 if it was configured
as maxPerDoc `0` due to `Math.min`
2025-02-10 23:28:40 +00:00
Sasha
98fec35368 fix(db-postgres): incorrect pagination results when querying hasMany relationships multiple times (#11096)
Fixes https://github.com/payloadcms/payload/issues/10810

This was caused by using `COUNT(*)` aggregation instead of
`COUNT(DISTINCT table.id)`. However, we want to use `COUNT(*)` because
`COUNT(DISTINCT table.id)` is slow on large tables. Now we fallback to
`COUNT(DISTINCT table.id)` only when `COUNT(*)` cannot work properly.

Example of a query that leads to incorrect `totalDocs`:
```ts
const res = await payload.find({
  collection: 'directors',
  limit: 10,
  where: {
    or: [
      {
        movies: {
          equals: movie2.id,
        },
      },
      {
        movies: {
          equals: movie1.id,
        },
      },
      {
        movies: {
          equals: movie1.id,
        },
      },
    ],
  },
})
```
2025-02-11 01:16:18 +02:00
Jarrod Flesch
fde526e07f fix: set initialValues alongside values during onSuccess (#10825)
### What?
Initial values should be set from the server when `acceptValues` is
true.

### Why?
This is needed since we take the values from the server after a
successful form submission.

### How?
Add `initialValue` into `serverPropsToAccept` when `acceptValues` is
true.

Fixes https://github.com/payloadcms/payload/issues/10820

---------

Co-authored-by: Alessio Gravili <alessio@gravili.de>
2025-02-10 16:49:06 -05:00
James Mikrut
5dadccea39 feat: adds new jobs.shouldAutoRun property (#11092)
Adds a `shouldAutoRun` property to the `jobs` config to be able to have
fine-grained control over if jobs should be run. This is helpful in
cases where you may have many horizontally scaled compute instances, and
only one instance should be responsible for running jobs.
2025-02-10 21:43:20 +00:00
Jarrod Flesch
d2fe9b0807 fix(db-mongodb): ensures same level operators are respected (#11087)
### What?
If you had multiple operator constraints on a single field, the last one
defined would be the only one used.

Example:
```ts
where: {
  id: {
    in: [doc2.id],
    not_in: [], // <-- only respected this operator constraint
  },
}
```

and
```ts
where: {
  id: {
    not_in: [],
    in: [doc2.id], // <-- only respected this operator constraint
  },
}
```

They would yield different results.

### Why?
The results were not merged into an `$and` query inside parseParams.

### How?
Merges the results within an `$and` constraint.

Fixes https://github.com/payloadcms/payload/issues/10944

Supersedes https://github.com/payloadcms/payload/pull/11011
2025-02-10 16:29:08 -05:00
Markus
95ec57575d fix(storage-s3): sockets not closing (#11015)
### What?
Within collections using the `storage-s3` plugins, we eventually start
receiving the following warnings:

`@smithy/node-http-handler:WARN socket usage at capacity=50 and 156
additional requests are enqueued. See
https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/node-configuring-maxsockets.html
or increase socketAcquisitionWarningTimeout=(millis) in the
NodeHttpHandler config.`

Also referenced in this issue: #6382

The
[solution](https://github.com/payloadcms/payload/issues/6382#issuecomment-2325468104)
provided by @denolfe in that issue only delayed the reappearance of the
problem somewhat, but did not resolve it.

### Why?
As far as I understand, in the `staticHandler` of the plugin, when
getting items from storage, and they are currently cached, the cached
results are immediately returned without handling the stream. As per
[this](https://github.com/aws/aws-sdk-js-v3/blob/main/supplemental-docs/CLIENTS.md#nodejs-requesthandler)
entry in the aws-sdk docs, if the streaming response is not read, or
manually destroyed, a socket might not properly close.

### How?
Before returning the cached items, manually destroy the streaming
response to make certain the socket is being properly closed.
Additionally, add an error check to also consume/destroy the streaming
response in case an error occurs, to not leave orphaned sockets.

Fixes #6382
2025-02-10 15:17:30 -05:00
Paul
430ebd42ff feat: add timezone support on date fields (#10896)
Adds support for timezone selection on date fields.

### Summary

New `admin.timezones` config:

```ts
{
  // ...
  admin: {
    // ...
    timezones: {
      supportedTimezones: ({ defaultTimezones }) => [
        ...defaultTimezones,
        { label: '(GMT-6) Monterrey, Nuevo Leon', value: 'America/Monterrey' },
      ],
      defaultTimezone: 'America/Monterrey',
    },
  }
}
```

New `timezone` property on date fields:

```ts
{
  type: 'date',
  name: 'date',
  timezone: true,
}
```

### Configuration

All date fields now accept `timezone: true` to enable this feature,
which will inject a new field into the configuration using the date
field's name to construct the name for the timezone column. So
`publishingDate` will have `publishingDate_tz` as an accompanying
column. This new field is inserted during config sanitisation.

Dates continue to be stored in UTC, this will help maintain dates
without needing a migration and it makes it easier for data to be
manipulated as needed. Mongodb also has a restriction around storing
dates only as UTC.

All timezones are stored by their IANA names so it's compatible with
browser APIs. There is a newly generated type for `SupportedTimezones`
which is reused across fields.

We handle timezone calculations via a new package `@date-fns/tz` which
we will be using in the future for handling timezone aware scheduled
publishing/unpublishing and more.

### UI

Dark mode

![image](https://github.com/user-attachments/assets/fcebdb7f-be01-4382-a1ce-3369f72b4309)

Light mode

![image](https://github.com/user-attachments/assets/dee2f1c6-4d0c-49e9-b6c8-a51a83a5e864)
2025-02-10 15:02:53 -05:00
Jacob Fletcher
3415ba81ac chore: updates CODEOWNERS (#11088) 2025-02-10 14:51:07 -05:00
Germán Jabloñski
fa18923317 fix(richtext-lexical): improve keyboard navigation on DecoratorNodes (#11022)
Fixes #8506



https://github.com/user-attachments/assets/a5e26f18-2557-4f19-bd89-73f246200fa5
2025-02-10 19:22:25 +00:00
James Mikrut
91a0f90649 fix(next): allows relative live preview urls (#11083)
We now properly allow relative live preview URLs which is handy if
you're deploying on a platform like Vercel and do not know what the
preview domain is going to end up being at build time.

This PR also removes some problematic code in the website template which
hard-codes the protocol to `https://` in production even if you're
running locally.

Fixes #11070
2025-02-10 18:20:34 +00:00
Alessio Gravili
b15a7e3c72 chore(richtext-lexical): add test converage for internal links (#11075)
Adds e2e test coverage for creating internal links, ensuring they are saved and that depth+population works.

This test will prevent regression of https://github.com/payloadcms/payload/issues/11062
2025-02-10 16:10:39 +00:00
Patrik
d56de79671 docs: adds usePayloadAPI hook to React Hooks documentation (#11079)
Adds documentation for the `usePayloadAPI` hook to the React Hooks
documentation.

The new section provides details on how the hook works, its parameters,
return values, and example usage.

**Changes:**
- Added `usePayloadAPI` documentation to the React Hooks page.
- Explained its purpose, arguments, and return values.
- Included an example demonstrating how to fetch data and update request
parameters dynamically.

Fixes: #10969
2025-02-10 11:01:45 -05:00
Patrik
87ba7f77aa docs: fix typo in BlockquoteFeature name (#11078)
### What

Before, richText docs were showing a feature name spelt as
`BlockQuoteFeature`.

### How?

 However, the accurate spelling of the feature is `BlockquoteFeature`.
2025-02-10 10:14:32 -05:00
Alessio Gravili
9fb7160c2c fix(richtext-lexical): toggling between internal and custom links does not update fields (#11074)
Fixes https://github.com/payloadcms/payload/issues/11062

In https://github.com/payloadcms/payload/pull/9869 we fixed a bug where `data` passed to lexical fields did not reflect the document data. Our LinkFeature, however, was depending on this incorrect behavior. This PR updates the LinkFeature field conditions to depend on the `siblingData` instead of `data`
2025-02-10 07:05:23 +00:00
Alessio Gravili
c6c65ac842 chore: ensure jest respects PAYLOAD_DATABASE env variable (#11065)
Previously, if the `PAYLOAD_DATABASE` env variable was set to `postgres`, it would still start up the mongo memory db and write the mongo db adapter.
2025-02-08 23:25:00 +00:00
Nathan Clevenger
dc56acbdaf docs: typo in jobs queue workflows (#11063) 2025-02-08 10:17:47 -05:00
Alessio Gravili
6a99677d15 fix: unhelpful "cannot overwrite model once compiled" errors swallowing actual error (#11057)
If an error is thrown during the payload init process, it gets ignored and an unhelpful, meaningless

` ⨯ OverwriteModelError: Cannot overwrite ___ model once compiled.`
 
error is thrown instead. The actual error that caused this will never be logged. This PR fixes this and ensures the actual error is logged.
 
 ## Why did this happen?
 
If an error is thrown during the init process, it is caught and handled by the `src/utilities/routeError.ts` - this helper properly logs the error using pino.
The problem is that pino did not exist, as payload did not finish initializing - it errored during it. So, it tries to initialize payload again before logging the error... which will fail again. If payload failed initializing the first time, it will fail the second time. => No error is logged.

This PR ensures the error is logged using `console.error()` if the originating error was thrown during the payload init process, instead of attempting to initialize it again and again
2025-02-08 07:32:03 +00:00
Alessio Gravili
6d48cf9bbf fix: error when passing functions to array or block fields labels property (#11056)
Fixes https://github.com/payloadcms/payload/issues/11055

Functions passed to array field, block field or block `labels` were not properly handled in the client config, causing those functions to be sent to the client. This leads to a "Functions cannot be passed directly to Client Component" error
2025-02-07 21:52:01 +00:00
Alessio Gravili
d7a7fbf93a feat(richtext-lexical): expose client config to client features (#11054)
This PR exposes the `ClientConfig` as an argument to the lexical `ClientFeature`. This is a requirement for https://github.com/payloadcms/payload/pull/10905, as we need to get the ClientBlocks from the `clientConfig.blocksMap` if they are strings.

## Example

```tsx
export const BlocksFeatureClient = createClientFeature(
  ({ config, featureClientSchemaMap, props, schemaPath }) => { // <= config is the new argument
  	
  	// Return ClientFeature
})
```
2025-02-07 21:22:38 +00:00
Germán Jabloñski
5a5385423e chore(richtext-lexical): fix unchecked indexed access (part 4) (#11048) 2025-02-07 16:24:57 +00:00
Boyan Bratvanov
ac6f4e2c86 docs(plugin-multi-tenant): update tenantsArrayField config options (#11045) 2025-02-07 10:30:20 -05:00
Germán Jabloñski
886bd94fc3 fix(richtext-lexical): fixed the positioning of the button to add columns or rows in tables (#11050)
Fixes #11042



https://github.com/user-attachments/assets/7b51930f-2861-4661-9551-f1952b7a972b
2025-02-07 10:30:06 -05:00
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
386 changed files with 10472 additions and 7209 deletions

25
.github/CODEOWNERS vendored
View File

@@ -1,24 +1,35 @@
# Order matters. The last matching pattern takes precedence.
### Package Exports ###
### Package Exports
**/exports/ @denolfe @jmikrut @DanRibbens
### Packages ###
### Packages
/packages/plugin-cloud*/src/ @denolfe @jmikrut @DanRibbens
/packages/email-*/src/ @denolfe @jmikrut @DanRibbens
/packages/live-preview*/src/ @jacobsfletch
/packages/plugin-stripe/src/ @jacobsfletch
/packages/plugin-multi-tenant/src/ @JarrodMFlesch
/packages/richtext-*/src/ @AlessioGr @GermanJablo
/packages/next/src/ @jmikrut @jacobsfletch @AlessioGr @JarrodMFlesch
/packages/ui/src/ @jmikrut @jacobsfletch @AlessioGr @JarrodMFlesch
/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 ###
/templates/_data/ @denolfe @jmikrut @DanRibbens
/templates/_template/ @denolfe @jmikrut @DanRibbens
### Build Files ###
**/tsconfig*.json @denolfe @jmikrut @DanRibbens @AlessioGr
### Build Files
**/tsconfig*.json @denolfe @jmikrut @DanRibbens @AlessioGr @GermanJablo
**/jest.config.js @denolfe @jmikrut @DanRibbens @AlessioGr
### Root ###
### Root
/package.json @denolfe @jmikrut @DanRibbens
/tools/ @denolfe @jmikrut @DanRibbens
/.husky/ @denolfe @jmikrut @DanRibbens

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:
@@ -1016,3 +1036,82 @@ export const MySetStepNavComponent: React.FC<{
return null
}
```
## usePayloadAPI
The `usePayloadAPI` hook is a useful tool for making REST API requests to your Payload instance and handling responses reactively. It allows you to fetch and interact with data while automatically updating when parameters change.
This hook returns an array with two elements:
1. An object containing the API response.
2. A set of methods to modify request parameters.
**Example:**
```tsx
'use client'
import { usePayloadAPI } from '@payloadcms/ui'
const MyComponent: React.FC = () => {
// Fetch data from a collection item using its ID
const [{ data, error, isLoading }, { setParams }] = usePayloadAPI(
'/api/posts/123',
{ initialParams: { depth: 1 } }
)
if (isLoading) return <p>Loading...</p>
if (error) return <p>Error: {error.message}</p>
return (
<div>
<h1>{data?.title}</h1>
<button onClick={() => setParams({ cacheBust: Date.now() })}>
Refresh Data
</button>
</div>
)
}
```
**Arguments:**
| Property | Description |
| ------------- | ----------------------------------------------------------------------------------------------- |
| **`url`** | The API endpoint to fetch data from. Relative URLs will be prefixed with the Payload API route. |
| **`options`** | An object containing initial request parameters and initial state configuration. |
The `options` argument accepts the following properties:
| Property | Description |
| ------------------- | --------------------------------------------------------------------------------------------------- |
| **`initialData`** | Uses this data instead of making an initial request. If not provided, the request runs immediately. |
| **`initialParams`** | Defines the initial parameters to use in the request. Defaults to an empty object `{}`. |
**Returned Value:**
The first item in the returned array is an object containing the following properties:
| Property | Description |
| --------------- | -------------------------------------------------------- |
| **`data`** | The API response data. |
| **`error`** | If an error occurs, this contains the error object. |
| **`isLoading`** | A boolean indicating whether the request is in progress. |
The second item is an object with the following methods:
| Property | Description |
| --------------- | ----------------------------------------------------------- |
| **`setParams`** | Updates request parameters, triggering a refetch if needed. |
#### Updating Data
The `setParams` function can be used to update the request and trigger a refetch:
```tsx
setParams({ depth: 2 })
```
This is useful for scenarios where you need to trigger another fetch regardless of the `url` argument changing.

View File

@@ -57,7 +57,7 @@ The `admin` directory contains all the _pages_ related to the interface itself,
<Banner type="warning">
**Note:**
If you don't intend to use the Admin Panel, [REST API](../rest/overview), or [GraphQL API](../graphql/overview), you can opt-out by simply deleting their corresponding directories within your Next.js app. The overhead, however, is completely constrained to these routes, and will not slow down or affect Payload outside when not in use.
If you don't intend to use the Admin Panel, [REST API](../rest-api/overview), or [GraphQL API](../graphql/overview), you can opt-out by simply deleting their corresponding directories within your Next.js app. The overhead, however, is completely constrained to these routes, and will not slow down or affect Payload outside when not in use.
</Banner>
Finally, the `custom.scss` file is where you can add or override globally-oriented styles in the Admin Panel, such as modify the color palette. Customizing the look and feel through CSS alone is a powerful feature of the Admin Panel, [more on that here](./customizing-css).
@@ -99,6 +99,7 @@ The following options are available:
| **`routes`** | Replace built-in Admin Panel routes with your own custom routes. [More details](#customizing-routes). |
| **`suppressHydrationWarning`** | If set to `true`, suppresses React hydration mismatch warnings during the hydration of the root `<html>` tag. Defaults to `false`. |
| **`theme`** | Restrict the Admin Panel theme to use only one of your choice. Default is `all`. |
| **`timezones`** | Configure the timezone settings for the admin panel. [More details](#timezones) |
| **`user`** | The `slug` of the Collection that you want to allow to login to the Admin Panel. [More details](#the-admin-user-collection). |
<Banner type="success">
@@ -242,3 +243,21 @@ The Payload Admin Panel is translated in over [30 languages and counting](https:
## Light and Dark Modes
Users in the Admin Panel have the ability to choose between light mode and dark mode for their editing experience. Users can select their preferred theme from their account page. Once selected, it is saved to their user's preferences and persisted across sessions and devices. If no theme was selected, the Admin Panel will automatically detect the operation system's theme and use that as the default.
## Timezones
The `admin.timezones` configuration allows you to configure timezone settings for the Admin Panel. You can customise the available list of timezones and in the future configure the default timezone for the Admin Panel and for all users.
The following options are available:
| Option | Description |
| ----------------- | ----------------------------------------------- |
| `supportedTimezones` | An array of label/value options for selectable timezones where the value is the IANA name eg. `America/Detroit` |
| `defaultTimezone` | The `value` of the default selected timezone. eg. `America/Los_Angeles` |
We validate the supported timezones array by checking the value against the list of IANA timezones supported via the Intl API, specifically `Intl.supportedValuesOf('timeZone')`.
<Banner type="info">
**Important**
You must enable timezones on each individual date field via `timezone: true`. See [Date Fields](../fields/overview#date) for more information.
</Banner>

View File

@@ -181,7 +181,7 @@ If none was in either location, Payload will finally check the `dist` directory.
### Customizing the Config Location
In addition to the above automated detection, you can specify your own location for the Payload Config. This can be useful in situations where your config is not in a standard location, or you wish to switch between multiple configurations. To do this, Payload exposes an [Environment Variable](./environment-variables) to bypass all automatic config detection.
In addition to the above automated detection, you can specify your own location for the Payload Config. This can be useful in situations where your config is not in a standard location, or you wish to switch between multiple configurations. To do this, Payload exposes an [Environment Variable](../configuration/environment-vars) to bypass all automatic config detection.
To use a custom config location, set the `PAYLOAD_CONFIG_PATH` environment variable:

View File

@@ -43,6 +43,7 @@ export const MyDateField: Field = {
| **`required`** | Require this field to have a value. |
| **`admin`** | Admin-specific configuration. [More details](#admin-options). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| **`timezone`** * | Set to `true` to enable timezone selection on this field. [More details](#timezones). |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema |
| **`virtual`** | Provide `true` to disable field in the database. See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) |
@@ -222,3 +223,23 @@ export const CustomDateFieldLabelClient: DateFieldLabelClientComponent = ({
}
```
## Timezones
To enable timezone selection on a Date field, set the `timezone` property to `true`:
```ts
{
name: 'date',
type: 'date',
timezone: true,
}
```
This will add a dropdown to the date picker that allows users to select a timezone. The selected timezone will be saved in the database along with the date in a new column named `date_tz`.
You can customise the available list of timezones in the [global admin config](../admin/overview#timezones).
<Banner type='info'>
**Good to know:**
The date itself will be stored in UTC so it's up to you to handle the conversion to the user's timezone when displaying the date in your frontend.
</Banner>

View File

@@ -29,7 +29,7 @@ As mentioned above, you can queue jobs, but the jobs won't run unless a worker p
#### Cron jobs
You can use the jobs.autoRun property to configure cron jobs:
You can use the `jobs.autoRun` property to configure cron jobs:
```ts
export default buildConfig({
@@ -47,6 +47,12 @@ export default buildConfig({
},
// add as many cron jobs as you want
],
shouldAutoRun: async (payload) => {
// Tell Payload if it should run jobs or not.
// This function will be invoked each time Payload goes to pick up and run jobs.
// If this function ever returns false, the cron schedule will be stopped.
return true
}
},
})
```

View File

@@ -20,7 +20,7 @@ The most important aspect of a Workflow is the `handler`, where you can declare
However, importantly, tasks that have successfully been completed will simply re-return the cached and saved output without running again. The Workflow will pick back up where it failed and only task from the failure point onward will be re-executed.
To define a JS-based workflow, simply add a workflow to the `jobs.wokflows` array in your Payload config. A workflow consists of the following fields:
To define a JS-based workflow, simply add a workflow to the `jobs.workflows` array in your Payload config. A workflow consists of the following fields:
| Option | Description |
| --------------------------- | -------------------------------------------------------------------------------- |

View File

@@ -109,10 +109,12 @@ The following arguments are provided to the `url` function:
| **`globalConfig`** | The Global Admin Config of the Document being edited. [More details](../configuration/globals#admin-options). |
| **`req`** | The Payload Request object. |
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:
You can return either an absolute URL or relative URL from this function. If you don't know the URL of your frontend at build-time, you can return a relative URL, and in that case, Payload will automatically construct an absolute URL by injecting the protocol, domain, and port from your browser window. Returning a relative URL is helpful for platforms like Vercel where you may have preview deployment URLs that are unknown at build time.
If your application requires a fully qualified URL, or you are attempting to preview with a frontend on a different domain, 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.
@@ -122,6 +122,18 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
* Access configuration for the array field
*/
arrayFieldAccess?: ArrayField['access']
/**
* Name of the array field
*
* @default 'tenants'
*/
arrayFieldName?: string
/**
* Name of the tenant field
*
* @default 'tenant'
*/
arrayTenantFieldName?: string
/**
* When `includeDefaultField` is `true`, the field will be added to the users collection automatically
*/
@@ -137,6 +149,8 @@ type MultiTenantPluginConfig<ConfigTypes = unknown> = {
}
| {
arrayFieldAccess?: never
arrayFieldName?: string
arrayTenantFieldName?: string
/**
* When `includeDefaultField` is `false`, you must include the field on your users collection manually
*/

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

@@ -578,7 +578,7 @@ Each endpoint object needs to have:
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`path`** | A string for the endpoint route after the collection or globals slug |
| **`method`** | The lowercase HTTP verb to use: 'get', 'head', 'post', 'put', 'delete', 'connect' or 'options' |
| **`handler`** | A function or array of functions to be called with **req**, **res** and **next** arguments. [Next.js](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) |
| **`handler`** | A function that accepts **req** - `PayloadRequest` object which contains [Web Request](https://developer.mozilla.org/en-US/docs/Web/API/Request) properties, currently authenticated `user` and the Local API instance `payload`. |
| **`root`** | When `true`, defines the endpoint on the root Next.js app, bypassing Payload handlers and the `routes.api` subpath. Note: this only applies to top-level endpoints of your Payload Config, endpoints defined on `collections` or `globals` cannot be root. |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |

View File

@@ -161,7 +161,7 @@ Here's an overview of all the included features:
| **`CheckListFeature`** | Yes | Adds checklists |
| **`LinkFeature`** | Yes | Allows you to create internal and external links |
| **`RelationshipFeature`** | Yes | Allows you to create block-level (not inline) relationships to other documents |
| **`BlockQuoteFeature`** | Yes | Allows you to create block-level quotes |
| **`BlockquoteFeature`** | Yes | Allows you to create block-level quotes |
| **`UploadFeature`** | Yes | Allows you to create block-level upload nodes - this supports all kinds of uploads, not just images |
| **`HorizontalRuleFeature`** | Yes | Horizontal rules / separators. Basically displays an `<hr>` element |
| **`InlineToolbarFeature`** | Yes | The inline toolbar is the floating toolbar which appears when you select text. This toolbar only contains actions relevant for selected text |
@@ -174,7 +174,7 @@ Notice how even the toolbars are features? That's how extensible our lexical edi
## Creating your own, custom Feature
You can find more information about creating your own feature in our [building custom feature docs](../rich-text/building-custom-features).
You can find more information about creating your own feature in our [building custom feature docs](../rich-text/custom-features).
## TypeScript

View File

@@ -113,7 +113,24 @@ _An asterisk denotes that an option is required._
### Payload-wide Upload Options
Upload options are specifiable on a Collection by Collection basis, you can also control app wide options by passing your base Payload Config an `upload` property containing an object supportive of all `Busboy` configuration options. [Click here](https://github.com/mscdex/busboy#api) for more documentation about what you can control.
Upload options are specifiable on a Collection by Collection basis, you can also control app wide options by passing your base Payload Config an `upload` property containing an object supportive of all `Busboy` configuration options.
| Option | Description |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`abortOnLimit`** | A boolean that, if `true`, returns HTTP 413 if a file exceeds the file size limit. If `false`, the file is truncated. Defaults to `false`. |
| **`createParentPath`** | Set to `true` to automatically create a directory path when moving files from a temporary directory or buffer. Defaults to `false`. |
| **`debug`** | A boolean that turns upload process logging on if `true`, or off if `false`. Useful for troubleshooting. Defaults to `false`. |
| **`limitHandler`** | A function which is invoked if the file is greater than configured limits. |
| **`parseNested`** | Set to `true` to turn `req.body` and `req.files` into nested structures. By default `req.body` and `req.files` are flat objects. Defaults to `false`. |
| **`preserveExtension`** | Preserves file extensions with the `safeFileNames` option. Limits file names to 3 characters if `true` or a custom length if a `number`, trimming from the start of the extension. |
| **`responseOnLimit`** | A `string` that is sent in the Response to a client if the file size limit is exceeded when used with `abortOnLimit`. |
| **`safeFileNames`** | Set to `true` to strip non-alphanumeric characters except dashes and underscores. Can also be set to a regex to determine what to strip. Defaults to `false`. |
| **`tempFileDir`** | A `string` path to store temporary files used when the `useTempFiles` option is set to `true`. Defaults to `'./tmp'`. |
| **`uploadTimeout`** | A `number` that defines how long to wait for data before aborting, specified in milliseconds. Set to `0` to disable timeout checks. Defaults to `60000`. |
| **`uriDecodeFileNames`** | Set to `true` to apply uri decoding to file names. Defaults to `false`. |
| **`useTempFiles`** | Set to `true` to store files to a temporary directory instead of in RAM, reducing memory usage for large files or many files. |
[Click here](https://github.com/mscdex/busboy#api) for more documentation about what you can control with `Busboy`.
A common example of what you might want to customize within Payload-wide Upload options would be to increase the allowed `fileSize` of uploads sent to Payload:

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

@@ -52,30 +52,37 @@ export async function parseParams({
// So we need to loop on keys again here to handle each operator independently
const pathOperators = where[relationOrPath]
if (typeof pathOperators === 'object') {
for (const operator of Object.keys(pathOperators)) {
if (validOperatorSet.has(operator as Operator)) {
const searchParam = await buildSearchParam({
collectionSlug,
fields,
globalSlug,
incomingPath: relationOrPath,
locale,
operator,
payload,
val: pathOperators[operator],
})
const validOperators = Object.keys(pathOperators).filter((operator) =>
validOperatorSet.has(operator as Operator),
)
for (const operator of validOperators) {
const searchParam = await buildSearchParam({
collectionSlug,
fields,
globalSlug,
incomingPath: relationOrPath,
locale,
operator,
payload,
val: pathOperators[operator],
})
if (searchParam?.value && searchParam?.path) {
result = {
...result,
[searchParam.path]: searchParam.value,
if (searchParam?.value && searchParam?.path) {
if (validOperators.length > 1) {
if (!result.$and) {
result.$and = []
}
} else if (typeof searchParam?.value === 'object') {
result = deepMergeWithCombinedArrays(result, searchParam.value, {
// dont clone Types.ObjectIDs
clone: false,
result.$and.push({
[searchParam.path]: searchParam.value,
})
} else {
result[searchParam.path] = searchParam.value
}
} else if (typeof searchParam?.value === 'object') {
result = deepMergeWithCombinedArrays(result, searchParam.value, {
// dont clone Types.ObjectIDs
clone: false,
})
}
}
}

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,7 +1,7 @@
import type { ChainedMethods } from '@payloadcms/drizzle/types'
import { chainMethods } from '@payloadcms/drizzle'
import { count } from 'drizzle-orm'
import { count, sql } from 'drizzle-orm'
import type { CountDistinct, SQLiteAdapter } from './types.js'
@@ -11,18 +11,27 @@ export const countDistinct: CountDistinct = async function countDistinct(
) {
const chainedMethods: ChainedMethods = []
joins.forEach(({ condition, table }) => {
// COUNT(DISTINCT id) is slow on large tables, so we only use DISTINCT if we have to
const visitedPaths = new Set([])
let useDistinct = false
joins.forEach(({ condition, queryPath, table }) => {
if (!useDistinct && queryPath) {
if (visitedPaths.has(queryPath)) {
useDistinct = true
} else {
visitedPaths.add(queryPath)
}
}
chainedMethods.push({
args: [table, condition],
method: 'leftJoin',
})
})
const countResult = await chainMethods({
methods: chainedMethods,
query: db
.select({
count: count(),
count: useDistinct ? sql`COUNT(DISTINCT ${this.tables[tableName].id})` : count(),
})
.from(this.tables[tableName])
.where(where),

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

@@ -1,4 +1,4 @@
import { count } from 'drizzle-orm'
import { count, sql } from 'drizzle-orm'
import type { ChainedMethods, TransactionPg } from '../types.js'
import type { BasePostgresAdapter, CountDistinct } from './types.js'
@@ -11,7 +11,17 @@ export const countDistinct: CountDistinct = async function countDistinct(
) {
const chainedMethods: ChainedMethods = []
joins.forEach(({ condition, table }) => {
// COUNT(DISTINCT id) is slow on large tables, so we only use DISTINCT if we have to
const visitedPaths = new Set([])
let useDistinct = false
joins.forEach(({ condition, queryPath, table }) => {
if (!useDistinct && queryPath) {
if (visitedPaths.has(queryPath)) {
useDistinct = true
} else {
visitedPaths.add(queryPath)
}
}
chainedMethods.push({
args: [table, condition],
method: 'leftJoin',
@@ -22,7 +32,7 @@ export const countDistinct: CountDistinct = async function countDistinct(
methods: chainedMethods,
query: (db as TransactionPg)
.select({
count: count(),
count: useDistinct ? sql`COUNT(DISTINCT ${this.tables[tableName].id})` : count(),
})
.from(this.tables[tableName])
.where(where),

View File

@@ -1,5 +1,5 @@
import type { SQL } from 'drizzle-orm'
import type { PgTableWithColumns } from 'drizzle-orm/pg-core'
import { type SQL } from 'drizzle-orm'
import { type PgTableWithColumns } from 'drizzle-orm/pg-core'
import type { GenericTable } from '../types.js'
import type { BuildQueryJoinAliases } from './buildQuery.js'
@@ -10,16 +10,18 @@ export const addJoinTable = ({
type,
condition,
joins,
queryPath,
table,
}: {
condition: SQL
joins: BuildQueryJoinAliases
queryPath?: string
table: GenericTable | PgTableWithColumns<any>
type?: 'innerJoin' | 'leftJoin' | 'rightJoin'
}) => {
const name = getNameFromDrizzleTable(table)
if (!joins.some((eachJoin) => getNameFromDrizzleTable(eachJoin.table) === name)) {
joins.push({ type, condition, table })
joins.push({ type, condition, queryPath, table })
}
}

View File

@@ -9,6 +9,7 @@ import { parseParams } from './parseParams.js'
export type BuildQueryJoinAliases = {
condition: SQL
queryPath?: string
table: GenericTable | PgTableWithColumns<any>
type?: 'innerJoin' | 'leftJoin' | 'rightJoin'
}[]

View File

@@ -363,7 +363,10 @@ export const getTableColumnFromPath = ({
const {
newAliasTable: aliasRelationshipTable,
newAliasTableName: aliasRelationshipTableName,
} = getTableAlias({ adapter, tableName: relationTableName })
} = getTableAlias({
adapter,
tableName: relationTableName,
})
if (selectLocale && field.localized && adapter.payload.config.localization) {
selectFields._locale = aliasRelationshipTable.locale
@@ -380,17 +383,21 @@ export const getTableColumnFromPath = ({
conditions.push(eq(aliasRelationshipTable.locale, locale))
}
joins.push({
addJoinTable({
condition: and(...conditions),
joins,
queryPath: `${constraintPath}.${field.name}`,
table: aliasRelationshipTable,
})
} else {
// Join in the relationships table
joins.push({
addJoinTable({
condition: and(
eq((aliasTable || adapter.tables[rootTableName]).id, aliasRelationshipTable.parent),
like(aliasRelationshipTable.path, `${constraintPath}${field.name}`),
),
joins,
queryPath: `${constraintPath}.${field.name}`,
table: aliasRelationshipTable,
})
}

View File

@@ -87,9 +87,11 @@ export const sanitizeQueryValue = ({
if (field.type === 'number') {
formattedValue = formattedValue.map((arrayVal) => parseFloat(arrayVal))
}
} else if (typeof formattedValue === 'number') {
formattedValue = [formattedValue]
}
if (!Array.isArray(formattedValue) || formattedValue.length === 0) {
if (!Array.isArray(formattedValue)) {
return null
}
}

View File

@@ -31,6 +31,7 @@ type Args = {
* ie. indexes, multiple columns, etc
*/
baseIndexes?: Record<string, RawIndex>
blockTableNamesMap: Record<string, number>
buildNumbers?: boolean
buildRelationships?: boolean
disableNotNull: boolean
@@ -67,6 +68,7 @@ export const buildTable = ({
baseColumns = {},
baseForeignKeys = {},
baseIndexes = {},
blockTableNamesMap,
disableNotNull,
disableRelsTableUnique = false,
disableUnique = false,
@@ -115,6 +117,7 @@ export const buildTable = ({
hasManyTextField,
} = traverseFields({
adapter,
blockTableNamesMap,
columns,
disableNotNull,
disableRelsTableUnique,

View File

@@ -52,6 +52,7 @@ export const buildRawSchema = ({
buildTable({
adapter,
blockTableNamesMap: {},
disableNotNull: !!collection?.versions?.drafts,
disableUnique: false,
fields: collection.flattenedFields,
@@ -69,6 +70,7 @@ export const buildRawSchema = ({
buildTable({
adapter,
blockTableNamesMap: {},
disableNotNull: !!collection.versions?.drafts,
disableUnique: true,
fields: versionFields,
@@ -88,6 +90,7 @@ export const buildRawSchema = ({
buildTable({
adapter,
blockTableNamesMap: {},
disableNotNull: !!global?.versions?.drafts,
disableUnique: false,
fields: global.flattenedFields,
@@ -109,6 +112,7 @@ export const buildRawSchema = ({
buildTable({
adapter,
blockTableNamesMap: {},
disableNotNull: !!global.versions?.drafts,
disableUnique: true,
fields: versionFields,

View File

@@ -18,13 +18,17 @@ import type {
import { createTableName } from '../createTableName.js'
import { buildIndexName } from '../utilities/buildIndexName.js'
import { hasLocalesTable } from '../utilities/hasLocalesTable.js'
import { validateExistingBlockIsIdentical } from '../utilities/validateExistingBlockIsIdentical.js'
import {
setInternalBlockIndex,
validateExistingBlockIsIdentical,
} from '../utilities/validateExistingBlockIsIdentical.js'
import { buildTable } from './build.js'
import { idToUUID } from './idToUUID.js'
import { withDefault } from './withDefault.js'
type Args = {
adapter: DrizzleAdapter
blockTableNamesMap: Record<string, number>
columnPrefix?: string
columns: Record<string, RawColumn>
disableNotNull: boolean
@@ -64,6 +68,7 @@ type Result = {
export const traverseFields = ({
adapter,
blockTableNamesMap,
columnPrefix,
columns,
disableNotNull,
@@ -239,6 +244,7 @@ export const traverseFields = ({
baseColumns,
baseForeignKeys,
baseIndexes,
blockTableNamesMap,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
@@ -344,7 +350,7 @@ export const traverseFields = ({
const disableNotNullFromHere = Boolean(field.admin?.condition) || disableNotNull
field.blocks.forEach((block) => {
const blockTableName = createTableName({
let blockTableName = createTableName({
adapter,
config: block,
parentTableName: rootTableName,
@@ -352,186 +358,195 @@ export const traverseFields = ({
throwValidationError,
versionsCustomName: versions,
})
if (!adapter.rawTables[blockTableName]) {
const baseColumns: Record<string, RawColumn> = {
_order: {
name: '_order',
type: 'integer',
notNull: true,
},
_parentID: {
name: '_parent_id',
type: rootTableIDColType,
notNull: true,
},
_path: {
name: '_path',
type: 'text',
notNull: true,
},
}
const baseIndexes: Record<string, RawIndex> = {
_orderIdx: {
name: `${blockTableName}_order_idx`,
on: '_order',
},
_parentIDIdx: {
name: `${blockTableName}_parent_id_idx`,
on: ['_parentID'],
},
_pathIdx: {
name: `${blockTableName}_path_idx`,
on: '_path',
},
}
const baseForeignKeys: Record<string, RawForeignKey> = {
_parentIdFk: {
name: `${blockTableName}_parent_id_fk`,
columns: ['_parentID'],
foreignColumns: [
{
name: 'id',
table: rootTableName,
},
],
onDelete: 'cascade',
},
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns._locale = {
name: '_locale',
type: 'enum',
locale: true,
notNull: true,
}
baseIndexes._localeIdx = {
name: `${blockTableName}_locale_idx`,
on: '_locale',
}
}
const {
hasLocalizedManyNumberField: subHasLocalizedManyNumberField,
hasLocalizedManyTextField: subHasLocalizedManyTextField,
hasLocalizedRelationshipField: subHasLocalizedRelationshipField,
hasManyNumberField: subHasManyNumberField,
hasManyTextField: subHasManyTextField,
relationsToBuild: subRelationsToBuild,
} = buildTable({
adapter,
baseColumns,
baseForeignKeys,
baseIndexes,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
fields: disableUnique ? idToUUID(block.flattenedFields) : block.flattenedFields,
rootRelationships: relationships,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
rootUniqueRelationships: uniqueRelationships,
setColumnID,
tableName: blockTableName,
versions,
withinLocalizedArrayOrBlock: isLocalized,
})
if (subHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = subHasLocalizedManyNumberField
}
if (subHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = subHasLocalizedRelationshipField
}
if (subHasLocalizedManyTextField) {
hasLocalizedManyTextField = subHasLocalizedManyTextField
}
if (subHasManyTextField) {
if (!hasManyTextField || subHasManyTextField === 'index') {
hasManyTextField = subHasManyTextField
}
}
if (subHasManyNumberField) {
if (!hasManyNumberField || subHasManyNumberField === 'index') {
hasManyNumberField = subHasManyNumberField
}
}
const blockRelations: Record<string, RawRelation> = {
_parentID: {
type: 'one',
fields: [
{
name: '_parentID',
table: blockTableName,
},
],
references: ['id'],
relationName: `_blocks_${block.slug}`,
to: rootTableName,
},
}
if (hasLocalesTable(block.fields)) {
blockRelations._locales = {
type: 'many',
relationName: '_locales',
to: `${blockTableName}${adapter.localesSuffix}`,
}
}
subRelationsToBuild.forEach(({ type, localized, target }, key) => {
if (type === 'one') {
const blockWithLocalized = localized
? `${blockTableName}${adapter.localesSuffix}`
: blockTableName
blockRelations[key] = {
type: 'one',
fields: [
{
name: key,
table: blockWithLocalized,
},
],
references: ['id'],
relationName: key,
to: target,
}
}
if (type === 'many') {
blockRelations[key] = {
type: 'many',
relationName: key,
to: target,
}
}
})
adapter.rawRelations[blockTableName] = blockRelations
} else if (process.env.NODE_ENV !== 'production' && !versions) {
validateExistingBlockIsIdentical({
if (typeof blockTableNamesMap[blockTableName] === 'undefined') {
blockTableNamesMap[blockTableName] = 1
} else if (
!validateExistingBlockIsIdentical({
block,
localized: field.localized,
rootTableName,
table: adapter.rawTables[blockTableName],
tableLocales: adapter.rawTables[`${blockTableName}${adapter.localesSuffix}`],
})
) {
blockTableNamesMap[blockTableName]++
blockTableName = `${blockTableName}_${blockTableNamesMap[blockTableName]}`
setInternalBlockIndex(block, blockTableNamesMap[blockTableName])
}
const baseColumns: Record<string, RawColumn> = {
_order: {
name: '_order',
type: 'integer',
notNull: true,
},
_parentID: {
name: '_parent_id',
type: rootTableIDColType,
notNull: true,
},
_path: {
name: '_path',
type: 'text',
notNull: true,
},
}
const baseIndexes: Record<string, RawIndex> = {
_orderIdx: {
name: `${blockTableName}_order_idx`,
on: '_order',
},
_parentIDIdx: {
name: `${blockTableName}_parent_id_idx`,
on: ['_parentID'],
},
_pathIdx: {
name: `${blockTableName}_path_idx`,
on: '_path',
},
}
const baseForeignKeys: Record<string, RawForeignKey> = {
_parentIdFk: {
name: `${blockTableName}_parent_id_fk`,
columns: ['_parentID'],
foreignColumns: [
{
name: 'id',
table: rootTableName,
},
],
onDelete: 'cascade',
},
}
const isLocalized =
Boolean(field.localized && adapter.payload.config.localization) ||
withinLocalizedArrayOrBlock ||
forceLocalized
if (isLocalized) {
baseColumns._locale = {
name: '_locale',
type: 'enum',
locale: true,
notNull: true,
}
baseIndexes._localeIdx = {
name: `${blockTableName}_locale_idx`,
on: '_locale',
}
}
const {
hasLocalizedManyNumberField: subHasLocalizedManyNumberField,
hasLocalizedManyTextField: subHasLocalizedManyTextField,
hasLocalizedRelationshipField: subHasLocalizedRelationshipField,
hasManyNumberField: subHasManyNumberField,
hasManyTextField: subHasManyTextField,
relationsToBuild: subRelationsToBuild,
} = buildTable({
adapter,
baseColumns,
baseForeignKeys,
baseIndexes,
blockTableNamesMap,
disableNotNull: disableNotNullFromHere,
disableRelsTableUnique: true,
disableUnique,
fields: disableUnique ? idToUUID(block.flattenedFields) : block.flattenedFields,
rootRelationships: relationships,
rootRelationsToBuild,
rootTableIDColType,
rootTableName,
rootUniqueRelationships: uniqueRelationships,
setColumnID,
tableName: blockTableName,
versions,
withinLocalizedArrayOrBlock: isLocalized,
})
if (subHasLocalizedManyNumberField) {
hasLocalizedManyNumberField = subHasLocalizedManyNumberField
}
if (subHasLocalizedRelationshipField) {
hasLocalizedRelationshipField = subHasLocalizedRelationshipField
}
if (subHasLocalizedManyTextField) {
hasLocalizedManyTextField = subHasLocalizedManyTextField
}
if (subHasManyTextField) {
if (!hasManyTextField || subHasManyTextField === 'index') {
hasManyTextField = subHasManyTextField
}
}
if (subHasManyNumberField) {
if (!hasManyNumberField || subHasManyNumberField === 'index') {
hasManyNumberField = subHasManyNumberField
}
}
const blockRelations: Record<string, RawRelation> = {
_parentID: {
type: 'one',
fields: [
{
name: '_parentID',
table: blockTableName,
},
],
references: ['id'],
relationName: `_blocks_${block.slug}`,
to: rootTableName,
},
}
if (hasLocalesTable(block.fields)) {
blockRelations._locales = {
type: 'many',
relationName: '_locales',
to: `${blockTableName}${adapter.localesSuffix}`,
}
}
subRelationsToBuild.forEach(({ type, localized, target }, key) => {
if (type === 'one') {
const blockWithLocalized = localized
? `${blockTableName}${adapter.localesSuffix}`
: blockTableName
blockRelations[key] = {
type: 'one',
fields: [
{
name: key,
table: blockWithLocalized,
},
],
references: ['id'],
relationName: key,
to: target,
}
}
if (type === 'many') {
blockRelations[key] = {
type: 'many',
relationName: key,
to: target,
}
}
})
adapter.rawRelations[blockTableName] = blockRelations
// blocks relationships are defined from the collection or globals table down to the block, bypassing any subBlocks
rootRelationsToBuild.set(`_blocks_${block.slug}`, {
type: 'many',
@@ -597,6 +612,7 @@ export const traverseFields = ({
hasManyTextField: groupHasManyTextField,
} = traverseFields({
adapter,
blockTableNamesMap,
columnPrefix: `${columnName}_`,
columns,
disableNotNull: disableNotNullFromHere,
@@ -812,6 +828,7 @@ export const traverseFields = ({
baseColumns,
baseForeignKeys,
baseIndexes,
blockTableNamesMap,
disableNotNull,
disableUnique,
fields: [],

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,4 +1,4 @@
import type { Block, Field } from 'payload'
import type { Block, Field, FlattenedBlock } from 'payload'
import { InvalidConfiguration } from 'payload'
import { fieldAffectsData, fieldHasSubFields, tabHasName } from 'payload/shared'
@@ -67,7 +67,7 @@ export const validateExistingBlockIsIdentical = ({
rootTableName,
table,
tableLocales,
}: Args): void => {
}: Args): boolean => {
const fieldNames = getFlattenedFieldNames(block.fields)
const missingField =
@@ -84,6 +84,7 @@ export const validateExistingBlockIsIdentical = ({
})
if (missingField) {
return false
throw new InvalidConfiguration(
`The table ${rootTableName} has multiple blocks with slug ${
block.slug
@@ -94,8 +95,14 @@ export const validateExistingBlockIsIdentical = ({
}
if (Boolean(localized) !== Boolean(table.columns._locale)) {
return false
throw new InvalidConfiguration(
`The table ${rootTableName} has multiple blocks with slug ${block.slug}, but the schemas do not match. One is localized, but another is not. Block schemas of the same name must match exactly.`,
)
}
}
const InternalBlockTableNameIndex = Symbol('InternalBlockTableNameIndex')
export const setInternalBlockIndex = (block: FlattenedBlock, index: number) => {
block[InternalBlockTableNameIndex] = index
}

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

@@ -61,6 +61,14 @@ type Props = {
readonly serverURL: string
} & DocumentSlots
const getAbsoluteUrl = (url) => {
try {
return new URL(url, window.location.origin).href
} catch {
return url
}
}
const PreviewView: React.FC<Props> = ({
collectionConfig,
config,
@@ -552,7 +560,7 @@ export const LivePreviewClient: React.FC<
readonly url: string
} & DocumentSlots
> = (props) => {
const { breakpoints, url } = props
const { breakpoints, url: incomingUrl } = props
const { collectionSlug, globalSlug } = useDocumentInfo()
const {
@@ -564,6 +572,11 @@ export const LivePreviewClient: React.FC<
getEntityConfig,
} = useConfig()
const url =
incomingUrl.startsWith('http://') || incomingUrl.startsWith('https://')
? incomingUrl
: getAbsoluteUrl(incomingUrl)
const { isPopupOpen, openPopupWindow, popupRef } = usePopupWindow({
eventType: 'payload-live-preview',
url,

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

@@ -105,8 +105,9 @@ export const payloadCloudPlugin =
const DEFAULT_CRON_JOB = {
cron: DEFAULT_CRON,
limit: DEFAULT_LIMIT,
queue: 'default (every minute)',
queue: 'default',
}
config.globals = [
...(config.globals || []),
{
@@ -130,9 +131,33 @@ export const payloadCloudPlugin =
const oldAutoRunCopy = config.jobs.autoRun ?? []
const hasExistingAutorun = Boolean(config.jobs.autoRun)
const newShouldAutoRun = async (payload: Payload) => {
if (process.env.PAYLOAD_CLOUD_JOBS_INSTANCE) {
const retrievedGlobal = await payload.findGlobal({
slug: 'payload-cloud-instance',
})
if (retrievedGlobal.instance === process.env.PAYLOAD_CLOUD_JOBS_INSTANCE) {
return true
} else {
process.env.PAYLOAD_CLOUD_JOBS_INSTANCE = ''
}
}
return false
}
if (!config.jobs.shouldAutoRun) {
config.jobs.shouldAutoRun = newShouldAutoRun
}
const newAutoRun = async (payload: Payload) => {
const instance = generateRandomString()
process.env.PAYLOAD_CLOUD_JOBS_INSTANCE = instance
await payload.updateGlobal({
slug: 'payload-cloud-instance',
data: {
@@ -140,16 +165,7 @@ export const payloadCloudPlugin =
},
})
await waitRandomTime()
const cloudInstance = await payload.findGlobal({
slug: 'payload-cloud-instance',
})
if (cloudInstance.instance !== instance) {
return []
}
if (!config.jobs?.autoRun) {
if (!hasExistingAutorun) {
return [DEFAULT_CRON_JOB]
}
@@ -160,11 +176,3 @@ export const payloadCloudPlugin =
return config
}
function waitRandomTime(): Promise<void> {
const min = 1000 // 1 second in milliseconds
const max = 5000 // 5 seconds in milliseconds
const randomTime = Math.floor(Math.random() * (max - min + 1)) + min
return new Promise((resolve) => setTimeout(resolve, randomTime))
}

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

@@ -97,6 +97,7 @@ export const createClientConfig = ({
meta: config.admin.meta,
routes: config.admin.routes,
theme: config.admin.theme,
timezones: config.admin.timezones,
user: config.admin.user,
}
if (config.admin.livePreview) {

View File

@@ -9,6 +9,7 @@ import type {
LocalizationConfigWithLabels,
LocalizationConfigWithNoLabels,
SanitizedConfig,
Timezone,
} from './types.js'
import { defaultUserCollection } from '../auth/defaultUser.js'
@@ -16,6 +17,7 @@ import { authRootEndpoints } from '../auth/endpoints/index.js'
import { sanitizeCollection } from '../collections/config/sanitize.js'
import { migrationsCollection } from '../database/migrations/migrationsCollection.js'
import { DuplicateCollection, InvalidConfiguration } from '../errors/index.js'
import { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js'
import { sanitizeGlobal } from '../globals/config/sanitize.js'
import { getLockedDocumentsCollection } from '../lockedDocuments/lockedDocumentsCollection.js'
import getPreferencesCollection from '../preferences/preferencesCollection.js'
@@ -56,6 +58,32 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
)
}
if (sanitizedConfig?.admin?.timezones) {
if (typeof sanitizedConfig?.admin?.timezones?.supportedTimezones === 'function') {
sanitizedConfig.admin.timezones.supportedTimezones =
sanitizedConfig.admin.timezones.supportedTimezones({ defaultTimezones })
}
if (!sanitizedConfig?.admin?.timezones?.supportedTimezones) {
sanitizedConfig.admin.timezones.supportedTimezones = defaultTimezones
}
} else {
sanitizedConfig.admin.timezones = {
supportedTimezones: defaultTimezones,
}
}
// Timezones supported by the Intl API
const _internalSupportedTimezones = Intl.supportedValuesOf('timeZone')
// We're casting here because it's already been sanitised above but TS still thinks it could be a function
;(sanitizedConfig.admin.timezones.supportedTimezones as Timezone[]).forEach((timezone) => {
if (!_internalSupportedTimezones.includes(timezone.value)) {
throw new InvalidConfiguration(
`Timezone ${timezone.value} is not supported by the current runtime via the Intl API.`,
)
}
})
return sanitizedConfig as unknown as Partial<SanitizedConfig>
}

View File

@@ -1,6 +1,7 @@
import type {
DefaultTranslationKeys,
DefaultTranslationsObject,
I18n,
I18nClient,
I18nOptions,
TFunction,
@@ -425,6 +426,32 @@ export const serverProps: (keyof ServerProps)[] = [
'permissions',
]
export type Timezone = {
label: string
value: string
}
type SupportedTimezonesFn = (args: { defaultTimezones: Timezone[] }) => Timezone[]
type TimezonesConfig = {
/**
* The default timezone to use for the admin panel.
*/
defaultTimezone?: string
/**
* Provide your own list of supported timezones for the admin panel
*
* Values should be IANA timezone names, eg. `America/New_York`
*
* We use `@date-fns/tz` to handle timezones
*/
supportedTimezones?: SupportedTimezonesFn | Timezone[]
}
type SanitizedTimezoneConfig = {
supportedTimezones: Timezone[]
} & Omit<TimezonesConfig, 'supportedTimezones'>
export type CustomComponent<TAdditionalProps extends object = Record<string, any>> =
PayloadComponent<ServerProps & TAdditionalProps, TAdditionalProps>
@@ -469,6 +496,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<
@@ -871,6 +906,10 @@ export type Config = {
* @default 'all' // The theme can be configured by users
*/
theme?: 'all' | 'dark' | 'light'
/**
* Configure timezone related settings for the admin panel.
*/
timezones?: TimezonesConfig
/** The slug of a Collection that you want to be used to log in to the Admin dashboard. */
user?: string
}
@@ -1122,7 +1161,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.
@@ -1131,6 +1179,9 @@ export type Config = {
}
export type SanitizedConfig = {
admin: {
timezones: SanitizedTimezoneConfig
} & DeepRequired<Config['admin']>
collections: SanitizedCollectionConfig[]
/** Default richtext editor to use for richText fields */
editor?: RichTextAdapter<any, any, any>
@@ -1155,7 +1206,7 @@ export type SanitizedConfig = {
// E.g. in packages/ui/src/graphics/Account/index.tsx in getComponent, if avatar.Component is casted to what it's supposed to be,
// the result type is different
DeepRequired<Config>,
'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload'
'admin' | 'collections' | 'editor' | 'endpoint' | 'globals' | 'i18n' | 'localization' | 'upload'
>
export type EditConfig = EditConfigWithoutRoot | EditConfigWithRoot

View File

@@ -12,6 +12,8 @@ export { defaults as collectionDefaults } from '../collections/config/defaults.j
export { serverProps } from '../config/types.js'
export { defaultTimezones } from '../fields/baseFields/timezone/defaultTimezones.js'
export {
fieldAffectsData,
fieldHasMaxDepth,

View File

@@ -0,0 +1,19 @@
import type { SelectField } from '../../config/types.js'
export const baseTimezoneField: (args: Partial<SelectField>) => SelectField = ({
name,
defaultValue,
options,
required,
}) => {
return {
name,
type: 'select',
admin: {
hidden: true,
},
defaultValue,
options,
required,
}
}

View File

@@ -0,0 +1,59 @@
import type { Timezone } from '../../../config/types.js'
/**
* List of supported timezones
*
* label: UTC offset and location
* value: IANA timezone name
*
* @example
* { label: '(UTC-12:00) International Date Line West', value: 'Dateline Standard Time' }
*/
export const defaultTimezones: Timezone[] = [
{ label: '(UTC-11:00) Midway Island, Samoa', value: 'Pacific/Midway' },
{ label: '(UTC-11:00) Niue', value: 'Pacific/Niue' },
{ label: '(UTC-10:00) Hawaii', value: 'Pacific/Honolulu' },
{ label: '(UTC-10:00) Cook Islands', value: 'Pacific/Rarotonga' },
{ label: '(UTC-09:00) Alaska', value: 'America/Anchorage' },
{ label: '(UTC-09:00) Gambier Islands', value: 'Pacific/Gambier' },
{ label: '(UTC-08:00) Pacific Time (US & Canada)', value: 'America/Los_Angeles' },
{ label: '(UTC-08:00) Tijuana, Baja California', value: 'America/Tijuana' },
{ label: '(UTC-07:00) Mountain Time (US & Canada)', value: 'America/Denver' },
{ label: '(UTC-07:00) Arizona (No DST)', value: 'America/Phoenix' },
{ label: '(UTC-06:00) Central Time (US & Canada)', value: 'America/Chicago' },
{ label: '(UTC-06:00) Central America', value: 'America/Guatemala' },
{ label: '(UTC-05:00) Eastern Time (US & Canada)', value: 'America/New_York' },
{ label: '(UTC-05:00) Bogota, Lima, Quito', value: 'America/Bogota' },
{ label: '(UTC-04:00) Caracas', value: 'America/Caracas' },
{ label: '(UTC-04:00) Santiago', value: 'America/Santiago' },
{ label: '(UTC-03:00) Buenos Aires', value: 'America/Buenos_Aires' },
{ label: '(UTC-03:00) Brasilia', value: 'America/Sao_Paulo' },
{ label: '(UTC-02:00) South Georgia', value: 'Atlantic/South_Georgia' },
{ label: '(UTC-01:00) Azores', value: 'Atlantic/Azores' },
{ label: '(UTC-01:00) Cape Verde', value: 'Atlantic/Cape_Verde' },
{ label: '(UTC+00:00) London (GMT)', value: 'Europe/London' },
{ label: '(UTC+01:00) Berlin, Paris', value: 'Europe/Berlin' },
{ label: '(UTC+01:00) Lagos', value: 'Africa/Lagos' },
{ label: '(UTC+02:00) Athens, Bucharest', value: 'Europe/Athens' },
{ label: '(UTC+02:00) Cairo', value: 'Africa/Cairo' },
{ label: '(UTC+03:00) Moscow, St. Petersburg', value: 'Europe/Moscow' },
{ label: '(UTC+03:00) Riyadh', value: 'Asia/Riyadh' },
{ label: '(UTC+04:00) Dubai', value: 'Asia/Dubai' },
{ label: '(UTC+04:00) Baku', value: 'Asia/Baku' },
{ label: '(UTC+05:00) Islamabad, Karachi', value: 'Asia/Karachi' },
{ label: '(UTC+05:00) Tashkent', value: 'Asia/Tashkent' },
{ label: '(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi', value: 'Asia/Calcutta' },
{ label: '(UTC+06:00) Dhaka', value: 'Asia/Dhaka' },
{ label: '(UTC+06:00) Almaty', value: 'Asia/Almaty' },
{ label: '(UTC+07:00) Jakarta', value: 'Asia/Jakarta' },
{ label: '(UTC+07:00) Bangkok', value: 'Asia/Bangkok' },
{ label: '(UTC+08:00) Beijing, Shanghai', value: 'Asia/Shanghai' },
{ label: '(UTC+08:00) Singapore', value: 'Asia/Singapore' },
{ label: '(UTC+09:00) Tokyo, Osaka, Sapporo', value: 'Asia/Tokyo' },
{ label: '(UTC+09:00) Seoul', value: 'Asia/Seoul' },
{ label: '(UTC+10:00) Sydney, Melbourne', value: 'Australia/Sydney' },
{ label: '(UTC+10:00) Guam, Port Moresby', value: 'Pacific/Guam' },
{ label: '(UTC+11:00) New Caledonia', value: 'Pacific/Noumea' },
{ label: '(UTC+12:00) Auckland, Wellington', value: 'Pacific/Auckland' },
{ label: '(UTC+12:00) Fiji', value: 'Pacific/Fiji' },
]

View File

@@ -1,7 +1,10 @@
/* eslint-disable perfectionist/sort-switch-case */
// Keep perfectionist/sort-switch-case disabled - it incorrectly messes up the ordering of the switch cases, causing it to break
import type { I18nClient } from '@payloadcms/translations'
import type {
AdminClient,
ArrayFieldClient,
BlockJSX,
BlocksFieldClient,
ClientBlock,
@@ -144,7 +147,27 @@ export const createClientField = ({
}
switch (incomingField.type) {
case 'array':
case 'array': {
if (incomingField.labels) {
const field = clientField as unknown as ArrayFieldClient
field.labels = {} as unknown as LabelsClient
if (incomingField.labels.singular) {
if (typeof incomingField.labels.singular === 'function') {
field.labels.singular = incomingField.labels.singular({ t: i18n.t })
} else {
field.labels.singular = incomingField.labels.singular
}
if (typeof incomingField.labels.plural === 'function') {
field.labels.plural = incomingField.labels.plural({ t: i18n.t })
} else {
field.labels.plural = incomingField.labels.plural
}
}
}
}
// falls through
case 'collapsible':
case 'group':
case 'row': {
@@ -168,6 +191,23 @@ export const createClientField = ({
case 'blocks': {
const field = clientField as unknown as BlocksFieldClient
if (incomingField.labels) {
field.labels = {} as unknown as LabelsClient
if (incomingField.labels.singular) {
if (typeof incomingField.labels.singular === 'function') {
field.labels.singular = incomingField.labels.singular({ t: i18n.t })
} else {
field.labels.singular = incomingField.labels.singular
}
if (typeof incomingField.labels.plural === 'function') {
field.labels.plural = incomingField.labels.plural({ t: i18n.t })
} else {
field.labels.plural = incomingField.labels.plural
}
}
}
if (incomingField.blocks?.length) {
for (let i = 0; i < incomingField.blocks.length; i++) {
const block = incomingField.blocks[i]

View File

@@ -14,6 +14,8 @@ import {
import { formatLabels, toWords } from '../../utilities/formatLabels.js'
import { baseBlockFields } from '../baseFields/baseBlockFields.js'
import { baseIDField } from '../baseFields/baseIDField.js'
import { baseTimezoneField } from '../baseFields/timezone/baseField.js'
import { defaultTimezones } from '../baseFields/timezone/defaultTimezones.js'
import { setDefaultBeforeDuplicate } from '../setDefaultBeforeDuplicate.js'
import { validations } from '../validations.js'
import { sanitizeJoinField } from './sanitizeJoinField.js'
@@ -287,6 +289,30 @@ export const sanitizeFields = async ({
}
fields[i] = field
// Insert our field after assignment
if (field.type === 'date' && field.timezone) {
const name = field.name + '_tz'
const defaultTimezone = config.admin.timezones.defaultTimezone
const supportedTimezones = config.admin.timezones.supportedTimezones
const options =
typeof supportedTimezones === 'function'
? supportedTimezones({ defaultTimezones })
: supportedTimezones
// Need to set the options here manually so that any database enums are generated correctly
// The UI component will import the options from the config
const timezoneField = baseTimezoneField({
name,
defaultValue: defaultTimezone,
options,
required: field.required,
})
fields.splice(++i, 0, timezoneField)
}
}
return fields

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'
@@ -632,6 +671,10 @@ export type DateField = {
date?: ConditionalDateProps
placeholder?: Record<string, string> | string
} & Admin
/**
* Enable timezone selection in the admin interface.
*/
timezone?: true
type: 'date'
validate?: DateFieldValidation
} & Omit<FieldBase, 'validate'>
@@ -639,7 +682,7 @@ export type DateField = {
export type DateFieldClient = {
admin?: AdminClient & Pick<DateField['admin'], 'date' | 'placeholder'>
} & FieldBaseClient &
Pick<DateField, 'type'>
Pick<DateField, 'timezone' | 'type'>
export type GroupField = {
admin?: {

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

@@ -377,11 +377,27 @@ export const checkbox: CheckboxFieldValidation = (value, { req: { t }, required
export type DateFieldValidation = Validate<Date, unknown, unknown, DateField>
export const date: DateFieldValidation = (value, { req: { t }, required }) => {
if (value && !isNaN(Date.parse(value.toString()))) {
export const date: DateFieldValidation = (
value,
{ name, req: { t }, required, siblingData, timezone },
) => {
const validDate = value && !isNaN(Date.parse(value.toString()))
// We need to also check for the timezone data based on this field's config
// We cannot do this inside the timezone field validation as it's visually hidden
const hasRequiredTimezone = timezone && required
const selectedTimezone: string = siblingData?.[`${name}_tz`]
// Always resolve to true if the field is not required, as timezone may be optional too then
const validTimezone = hasRequiredTimezone ? Boolean(selectedTimezone) : true
if (validDate && validTimezone) {
return true
}
if (validDate && !validTimezone) {
return t('validation:timezoneRequired')
}
if (value) {
return t('validation:notValidDate', { value })
}
@@ -510,7 +526,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 +543,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

@@ -729,9 +729,20 @@ export class BasePayload {
typeof this.config.jobs.autoRun === 'function'
? await this.config.jobs.autoRun(this)
: this.config.jobs.autoRun
await Promise.all(
cronJobs.map((cronConfig) => {
new Cron(cronConfig.cron ?? DEFAULT_CRON, async () => {
const job = new Cron(cronConfig.cron ?? DEFAULT_CRON, async () => {
if (typeof this.config.jobs.shouldAutoRun === 'function') {
const shouldAutoRun = await this.config.jobs.shouldAutoRun(this)
if (!shouldAutoRun) {
job.stop()
return false
}
}
await this.jobs.run({
limit: cronConfig.limit ?? DEFAULT_LIMIT,
queue: cronConfig.queue,
@@ -905,6 +916,8 @@ export const getPayload = async (
}
} catch (e) {
cached.promise = null
// add identifier to error object, so that our error logger in routeError.ts does not attempt to re-initialize getPayload
e.payloadInitError = true
throw e
}

View File

@@ -76,6 +76,13 @@ export type JobsConfig = {
* a new collection.
*/
jobsCollectionOverrides?: (args: { defaultJobsCollection: CollectionConfig }) => CollectionConfig
/**
* A function that will be executed before Payload picks up jobs which are configured by the `jobs.autorun` function.
* If this function returns true, jobs will be queried and picked up. If it returns false, jobs will not be run.
* @param payload
* @returns boolean
*/
shouldAutoRun?: (payload: Payload) => boolean | Promise<boolean>
/**
* Define all possible tasks here
*/

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
@@ -246,7 +248,7 @@ export function fieldsToJSONSchema(
return {
properties: Object.fromEntries(
fields.reduce((fieldSchemas, field) => {
fields.reduce((fieldSchemas, field, index) => {
const isRequired = fieldAffectsData(field) && fieldIsRequired(field)
if (isRequired) {
requiredFieldNames.add(field.name)
@@ -577,25 +579,37 @@ export function fieldsToJSONSchema(
}
case 'select': {
const optionEnums = buildOptionEnums(field.options)
// We get the previous field to check for a date in the case of a timezone select
// This works because timezone selects are always inserted right after a date with 'timezone: true'
const previousField = fields?.[index - 1]
const isTimezoneField =
previousField?.type === 'date' && previousField.timezone && field.name.includes('_tz')
if (field.hasMany) {
// Timezone selects should reference the supportedTimezones definition
if (isTimezoneField) {
fieldSchema = {
...baseFieldSchema,
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'string',
},
}
if (optionEnums?.length) {
;(fieldSchema.items as JSONSchema4).enum = optionEnums
$ref: `#/definitions/supportedTimezones`,
}
} else {
fieldSchema = {
...baseFieldSchema,
type: withNullableJSONSchemaType('string', isRequired),
}
if (optionEnums?.length) {
fieldSchema.enum = optionEnums
if (field.hasMany) {
fieldSchema = {
...baseFieldSchema,
type: withNullableJSONSchemaType('array', isRequired),
items: {
type: 'string',
},
}
if (optionEnums?.length) {
;(fieldSchema.items as JSONSchema4).enum = optionEnums
}
} else {
fieldSchema = {
...baseFieldSchema,
type: withNullableJSONSchemaType('string', isRequired),
}
if (optionEnums?.length) {
fieldSchema.enum = optionEnums
}
}
}
@@ -954,6 +968,18 @@ export function authCollectionToOperationsJSONSchema(
}
}
// Generates the JSON Schema for supported timezones
export function timezonesToJSONSchema(
supportedTimezones: SanitizedConfig['admin']['timezones']['supportedTimezones'],
): JSONSchema4 {
return {
description: 'Supported timezones in IANA format.',
enum: supportedTimezones.map((timezone) =>
typeof timezone === 'string' ? timezone : timezone.value,
),
}
}
function generateAuthOperationSchemas(collections: SanitizedCollectionConfig[]): JSONSchema4 {
const properties = collections.reduce((acc, collection) => {
if (collection.auth) {
@@ -1032,6 +1058,8 @@ export function configToJSONSchema(
{},
)
const timezoneDefinitions = timezonesToJSONSchema(config.admin.timezones.supportedTimezones)
const authOperationDefinitions = [...config.collections]
.filter(({ auth }) => Boolean(auth))
.reduce(
@@ -1055,6 +1083,7 @@ export function configToJSONSchema(
let jsonSchema: JSONSchema4 = {
additionalProperties: false,
definitions: {
supportedTimezones: timezoneDefinitions,
...entityDefinitions,
...Object.fromEntries(interfaceNameDefinitions),
...authOperationDefinitions,
@@ -1102,7 +1131,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

@@ -22,6 +22,19 @@ export const routeError = async ({
err: APIError
req: PayloadRequest | Request
}) => {
if ('payloadInitError' in err && err.payloadInitError === true) {
// do not attempt initializing Payload if the error is due to a failed initialization. Otherwise,
// it will cause an infinite loop of initialization attempts and endless error responses, without
// actually logging the error, as the error logging code will never be reached.
console.error(err)
return Response.json(
{
message: 'There was an error initializing Payload',
},
{ status: httpStatus.INTERNAL_SERVER_ERROR },
)
}
let payload = incomingReq && 'payload' in incomingReq && incomingReq?.payload
if (!payload) {
@@ -55,7 +68,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])

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