Compare commits

..

34 Commits

Author SHA1 Message Date
Jacob Fletcher
7d1c4d5dc4 scaffolds test 2025-02-17 17:26:27 -05: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
214 changed files with 3517 additions and 655 deletions

21
.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 @GermanJablo
### Templates ###
### Templates
/templates/_data/ @denolfe @jmikrut @DanRibbens
/templates/_template/ @denolfe @jmikrut @DanRibbens
### Build Files ###
### 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

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

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

View File

@@ -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,7 +109,9 @@ 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: ({ data, req }) => `${req.protocol}//${req.host}/${data.slug}` // highlight-line

View File

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

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

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-sqlite",
"version": "3.21.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.21.0",
"version": "3.22.0",
"description": "Vercel Postgres adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

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

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/email-nodemailer",
"version": "3.21.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.21.0",
"version": "3.22.0",
"description": "Payload Resend Email Adapter",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/graphql",
"version": "3.21.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.21.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.21.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.21.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.21.0",
"version": "3.22.0",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

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

View File

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

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/payload-cloud",
"version": "3.21.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.21.0",
"version": "3.22.0",
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
"keywords": [
"admin panel",

View File

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

View File

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

View File

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

@@ -426,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>
@@ -880,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
}
@@ -1149,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>
@@ -1173,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

@@ -671,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'>
@@ -678,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

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

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

@@ -248,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)
@@ -579,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
}
}
}
@@ -956,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) {
@@ -1034,6 +1058,8 @@ export function configToJSONSchema(
{},
)
const timezoneDefinitions = timezonesToJSONSchema(config.admin.timezones.supportedTimezones)
const authOperationDefinitions = [...config.collections]
.filter(({ auth }) => Boolean(auth))
.reduce(
@@ -1057,6 +1083,7 @@ export function configToJSONSchema(
let jsonSchema: JSONSchema4 = {
additionalProperties: false,
definitions: {
supportedTimezones: timezoneDefinitions,
...entityDefinitions,
...Object.fromEntries(interfaceNameDefinitions),
...authOperationDefinitions,

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) {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-cloud-storage",
"version": "3.21.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.21.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.21.0",
"version": "3.22.0",
"description": "Multi Tenant plugin for Payload",
"keywords": [
"payload",

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-redirects",
"version": "3.21.0",
"version": "3.22.0",
"description": "Redirects plugin for Payload",
"keywords": [
"payload",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -87,8 +87,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
const { getFormState } = useServerFunctions()
const schemaFieldsPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_blocks.${formData.blockType}.fields`
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(
initialLexicalFormState?.[formData.id]?.formState
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(() => {
return initialLexicalFormState?.[formData.id]?.formState
? {
...initialLexicalFormState?.[formData.id]?.formState,
blockName: {
@@ -98,11 +98,20 @@ export const BlockComponent: React.FC<Props> = (props) => {
value: formData.blockName,
},
}
: false,
)
: false
})
const hasMounted = useRef(false)
const prevCacheBuster = useRef(cacheBuster)
useEffect(() => {
setInitialState(false)
if (hasMounted.current) {
if (prevCacheBuster.current !== cacheBuster) {
setInitialState(false)
}
prevCacheBuster.current = cacheBuster
} else {
hasMounted.current = true
}
}, [cacheBuster])
const [CustomLabel, setCustomLabel] = React.useState<React.ReactNode | undefined>(
@@ -148,6 +157,22 @@ export const BlockComponent: React.FC<Props> = (props) => {
value: formData.blockName,
}
const newFormStateData: BlockFields = reduceFieldsToValues(
deepCopyObjectSimpleWithoutReactComponents(state),
true,
) as BlockFields
// Things like default values may come back from the server => update the node with the new data
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if (node && $isBlockNode(node)) {
const newData = newFormStateData
newData.blockType = formData.blockType
node.setFields(newData, true)
}
})
setInitialState(state)
setCustomLabel(state._components?.customComponents?.BlockLabel)
setCustomBlock(state._components?.customComponents?.Block)
@@ -166,6 +191,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
schemaFieldsPath,
id,
formData,
editor,
nodeKey,
initialState,
collectionSlug,
globalSlug,

View File

@@ -27,7 +27,7 @@ import { $getNodeByKey } from 'lexical'
import './index.scss'
import { deepCopyObjectSimpleWithoutReactComponents } from 'payload/shared'
import { deepCopyObjectSimpleWithoutReactComponents, reduceFieldsToValues } from 'payload/shared'
import { v4 as uuid } from 'uuid'
import type { InlineBlockFields } from '../../server/nodes/InlineBlocksNode.js'
@@ -86,11 +86,20 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
const firstTimeDrawer = useRef(false)
const [initialState, setInitialState] = React.useState<false | FormState | undefined>(
initialLexicalFormState?.[formData.id]?.formState,
() => initialLexicalFormState?.[formData.id]?.formState,
)
const hasMounted = useRef(false)
const prevCacheBuster = useRef(cacheBuster)
useEffect(() => {
setInitialState(false)
if (hasMounted.current) {
if (prevCacheBuster.current !== cacheBuster) {
setInitialState(false)
}
prevCacheBuster.current = cacheBuster
} else {
hasMounted.current = true
}
}, [cacheBuster])
const [CustomLabel, setCustomLabel] = React.useState<React.ReactNode | undefined>(
@@ -176,6 +185,22 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
})
if (state) {
const newFormStateData: InlineBlockFields = reduceFieldsToValues(
deepCopyObjectSimpleWithoutReactComponents(state),
true,
) as InlineBlockFields
// Things like default values may come back from the server => update the node with the new data
editor.update(() => {
const node = $getNodeByKey(nodeKey)
if (node && $isInlineBlockNode(node)) {
const newData = newFormStateData
newData.blockType = formData.blockType
node.setFields(newData, true)
}
})
setInitialState(state)
setCustomLabel(state['_components']?.customComponents?.BlockLabel)
setCustomBlock(state['_components']?.customComponents?.Block)
@@ -191,6 +216,8 @@ export const InlineBlockComponent: React.FC<Props> = (props) => {
}
}, [
getFormState,
editor,
nodeKey,
schemaFieldsPath,
id,
formData,

View File

@@ -91,37 +91,46 @@ function TableHoverActionsContainer({
{ editor },
)
if (tableDOMElement) {
const {
bottom: tableElemBottom,
height: tableElemHeight,
left: tableElemLeft,
right: tableElemRight,
if (!tableDOMElement) {
return
}
// this is the scrollable div container of the table (in case of overflow)
const tableContainerElement = (tableDOMElement as HTMLTableElement).parentElement
if (!tableContainerElement) {
return
}
const {
bottom: tableElemBottom,
height: tableElemHeight,
left: tableElemLeft,
right: tableElemRight,
width: tableElemWidth,
y: tableElemY,
} = tableContainerElement.getBoundingClientRect()
const { left: editorElemLeft, y: editorElemY } = anchorElem.getBoundingClientRect()
if (hoveredRowNode) {
setShownColumn(false)
setShownRow(true)
setPosition({
height: BUTTON_WIDTH_PX,
left: tableElemLeft - editorElemLeft,
top: tableElemBottom - editorElemY + 5,
width: tableElemWidth,
y: tableElemY,
} = (tableDOMElement as HTMLTableElement).getBoundingClientRect()
const { left: editorElemLeft, y: editorElemY } = anchorElem.getBoundingClientRect()
if (hoveredRowNode) {
setShownColumn(false)
setShownRow(true)
setPosition({
height: BUTTON_WIDTH_PX,
left: tableElemLeft - editorElemLeft,
top: tableElemBottom - editorElemY + 5,
width: tableElemWidth,
})
} else if (hoveredColumnNode) {
setShownColumn(true)
setShownRow(false)
setPosition({
height: tableElemHeight,
left: tableElemRight - editorElemLeft + 5,
top: tableElemY - editorElemY,
width: BUTTON_WIDTH_PX,
})
}
})
} else if (hoveredColumnNode) {
setShownColumn(true)
setShownRow(false)
setPosition({
height: tableElemHeight,
left: tableElemRight - editorElemLeft + 5,
top: tableElemY - editorElemY,
width: BUTTON_WIDTH_PX,
})
}
},
50,
@@ -218,6 +227,7 @@ function TableHoverActionsContainer({
<>
{isShownRow && (
<button
aria-label="Add Row"
className={editorConfig.editorConfig.lexical.theme.tableAddRows}
onClick={() => insertAction(true)}
style={{ ...position }}
@@ -226,6 +236,7 @@ function TableHoverActionsContainer({
)}
{isShownColumn && (
<button
aria-label="Add Column"
className={editorConfig.editorConfig.lexical.theme.tableAddColumns}
onClick={() => insertAction(false)}
style={{ ...position }}

View File

@@ -53,8 +53,8 @@ export function createLinkMatcherWithRegExp(
}
function findFirstMatch(text: string, matchers: LinkMatcher[]): LinkMatcherResult | null {
for (let i = 0; i < matchers.length; i++) {
const match = matchers[i](text)
for (const matcher of matchers) {
const match = matcher(text)
if (match != null) {
return match
@@ -66,8 +66,8 @@ function findFirstMatch(text: string, matchers: LinkMatcher[]): LinkMatcherResul
const PUNCTUATION_OR_SPACE = /[.,;\s]/
function isSeparator(char: string): boolean {
return PUNCTUATION_OR_SPACE.test(char)
function isSeparator(char: string | undefined): boolean {
return char !== undefined && PUNCTUATION_OR_SPACE.test(char)
}
function endsWithSeparator(textContent: string): boolean {
@@ -124,13 +124,13 @@ function isContentAroundIsValid(
nodes: TextNode[],
): boolean {
const contentBeforeIsValid =
matchStart > 0 ? isSeparator(text[matchStart - 1]) : isPreviousNodeValid(nodes[0])
matchStart > 0 ? isSeparator(text[matchStart - 1]) : isPreviousNodeValid(nodes[0]!)
if (!contentBeforeIsValid) {
return false
}
const contentAfterIsValid =
matchEnd < text.length ? isSeparator(text[matchEnd]) : isNextNodeValid(nodes[nodes.length - 1])
matchEnd < text.length ? isSeparator(text[matchEnd]) : isNextNodeValid(nodes[nodes.length - 1]!)
return contentAfterIsValid
}
@@ -153,7 +153,7 @@ function extractMatchingNodes(
const currentNodes = [...nodes]
while (currentNodes.length > 0) {
const currentNode = currentNodes[0]
const currentNode = currentNodes[0]!
const currentNodeText = currentNode.getTextContent()
const currentNodeLength = currentNodeText.length
const currentNodeStart = currentOffset
@@ -187,22 +187,22 @@ function $createAutoLinkNode_(
const linkNode = $createAutoLinkNode({ fields })
if (nodes.length === 1) {
let remainingTextNode = nodes[0]
let linkTextNode
if (startIndex === 0) {
;[linkTextNode, remainingTextNode] = remainingTextNode.splitText(endIndex)
} else {
;[, linkTextNode, remainingTextNode] = remainingTextNode.splitText(startIndex, endIndex)
const split = (
startIndex === 0 ? nodes[0]?.splitText(endIndex) : nodes[0]?.splitText(startIndex, endIndex)
)!
const [linkTextNode, remainingTextNode] = split
if (linkTextNode) {
const textNode = $createTextNode(match.text)
textNode.setFormat(linkTextNode.getFormat())
textNode.setDetail(linkTextNode.getDetail())
textNode.setStyle(linkTextNode.getStyle())
linkNode.append(textNode)
linkTextNode.replace(linkNode)
}
const textNode = $createTextNode(match.text)
textNode.setFormat(linkTextNode.getFormat())
textNode.setDetail(linkTextNode.getDetail())
textNode.setStyle(linkTextNode.getStyle())
linkNode.append(textNode)
linkTextNode.replace(linkNode)
return remainingTextNode
} else if (nodes.length > 1) {
const firstTextNode = nodes[0]
const firstTextNode = nodes[0]!
let offset = firstTextNode.getTextContent().length
let firstLinkTextNode
if (startIndex === 0) {
@@ -212,8 +212,7 @@ function $createAutoLinkNode_(
}
const linkNodes: LexicalNode[] = []
let remainingTextNode
for (let i = 1; i < nodes.length; i++) {
const currentNode = nodes[i]
nodes.forEach((currentNode) => {
const currentNodeText = currentNode.getTextContent()
const currentNodeLength = currentNodeText.length
const currentNodeStart = offset
@@ -223,30 +222,35 @@ function $createAutoLinkNode_(
linkNodes.push(currentNode)
} else {
const [linkTextNode, endNode] = currentNode.splitText(endIndex - currentNodeStart)
linkNodes.push(linkTextNode)
if (linkTextNode) {
linkNodes.push(linkTextNode)
}
remainingTextNode = endNode
}
}
offset += currentNodeLength
}
const selection = $getSelection()
const selectedTextNode = selection ? selection.getNodes().find($isTextNode) : undefined
const textNode = $createTextNode(firstLinkTextNode.getTextContent())
textNode.setFormat(firstLinkTextNode.getFormat())
textNode.setDetail(firstLinkTextNode.getDetail())
textNode.setStyle(firstLinkTextNode.getStyle())
linkNode.append(textNode, ...linkNodes)
// it does not preserve caret position if caret was at the first text node
// so we need to restore caret position
if (selectedTextNode && selectedTextNode === firstLinkTextNode) {
if ($isRangeSelection(selection)) {
textNode.select(selection.anchor.offset, selection.focus.offset)
} else if ($isNodeSelection(selection)) {
textNode.select(0, textNode.getTextContent().length)
})
if (firstLinkTextNode) {
const selection = $getSelection()
const selectedTextNode = selection ? selection.getNodes().find($isTextNode) : undefined
const textNode = $createTextNode(firstLinkTextNode.getTextContent())
textNode.setFormat(firstLinkTextNode.getFormat())
textNode.setDetail(firstLinkTextNode.getDetail())
textNode.setStyle(firstLinkTextNode.getStyle())
linkNode.append(textNode, ...linkNodes)
// it does not preserve caret position if caret was at the first text node
// so we need to restore caret position
if (selectedTextNode && selectedTextNode === firstLinkTextNode) {
if ($isRangeSelection(selection)) {
textNode.select(selection.anchor.offset, selection.focus.offset)
} else if ($isNodeSelection(selection)) {
textNode.select(0, textNode.getTextContent().length)
}
}
firstLinkTextNode.replace(linkNode)
return remainingTextNode
}
firstLinkTextNode.replace(linkNode)
return remainingTextNode
}
return undefined
}
@@ -378,7 +382,7 @@ function replaceWithChildren(node: ElementNode): LexicalNode[] {
const childrenLength = children.length
for (let j = childrenLength - 1; j >= 0; j--) {
node.insertAfter(children[j])
node.insertAfter(children[j]!)
}
node.remove()

View File

@@ -4,9 +4,12 @@ import type {
RadioField,
SanitizedConfig,
TextField,
TextFieldSingleValidation,
User,
} from 'payload'
import type { LinkFields } from '../nodes/types.js'
import { validateUrl, validateUrlMinimal } from '../../../lexical/utils/url.js'
export const getBaseFields = (
@@ -80,15 +83,14 @@ export const getBaseFields = (
},
label: ({ t }) => t('fields:enterURL'),
required: true,
// @ts-expect-error - TODO: fix this
validate: (value: string, options) => {
if (options?.siblingData?.linkType === 'internal') {
validate: ((value: string, options) => {
if ((options?.siblingData as LinkFields)?.linkType === 'internal') {
return // no validation needed, as no url should exist for internal links
}
if (!validateUrlMinimal(value)) {
return 'Invalid URL'
}
},
}) as TextFieldSingleValidation,
},
]
@@ -99,14 +101,16 @@ export const getBaseFields = (
value: 'internal',
})
;(baseFields[2] as TextField).admin = {
condition: ({ linkType }) => linkType !== 'internal',
condition: (_data, _siblingData) => {
return _siblingData.linkType !== 'internal'
},
}
baseFields.push({
name: 'doc',
admin: {
condition: ({ linkType }) => {
return linkType === 'internal'
condition: (_data, _siblingData) => {
return _siblingData.linkType === 'internal'
},
},
// when admin.hidden is a function we need to dynamically call hidden with the user to know if the collection should be shown

View File

@@ -85,7 +85,7 @@ function ToolbarGroupComponent({
}
return
}
const item = activeItems[0]
const item = activeItems[0]!
let label = item.key
if (item.label) {

View File

@@ -86,7 +86,7 @@ function ToolbarGroupComponent({
return
}
const item = activeItems[0]
setDropdownIcon(() => item.ChildComponent)
setDropdownIcon(() => item?.ChildComponent)
},
[group],
)

View File

@@ -5,7 +5,7 @@ import type {
LexicalNodeReplacement,
TextFormatType,
} from 'lexical'
import type { RichTextFieldClient } from 'payload'
import type { ClientConfig, RichTextFieldClient } from 'payload'
import type React from 'react'
import type { JSX } from 'react'
@@ -33,6 +33,7 @@ export type FeatureProviderClient<
clientFeatureProps: BaseClientFeatureProps<UnSanitizedClientFeatureProps>
feature:
| ((props: {
config: ClientConfig
featureClientSchemaMap: FeatureClientSchemaMap
/** unSanitizedEditorConfig.features, but mapped */
featureProviderMap: ClientFeatureProviderMap

View File

@@ -59,18 +59,19 @@ export const UploadFeature = createServerFeature<
if (props.collections) {
for (const collection in props.collections) {
clientProps.collections[collection] = {
hasExtraFields: props.collections[collection].fields.length >= 1,
hasExtraFields: props.collections[collection]!.fields.length >= 1,
}
}
}
const validRelationships = _config.collections.map((c) => c.slug) || []
for (const collection in props.collections) {
if (props.collections[collection].fields?.length) {
props.collections[collection].fields = await sanitizeFields({
for (const collectionKey in props.collections) {
const collection = props.collections[collectionKey]!
if (collection.fields?.length) {
collection.fields = await sanitizeFields({
config: _config as unknown as Config,
fields: props.collections[collection].fields,
fields: collection.fields,
parentIsLocalized,
requireFieldLevelRichTextEditor: isRoot,
validRelationships,
@@ -88,10 +89,11 @@ export const UploadFeature = createServerFeature<
const schemaMap: FieldSchemaMap = new Map()
for (const collection in props.collections) {
if (props.collections[collection].fields?.length) {
schemaMap.set(collection, {
fields: props.collections[collection].fields,
for (const collectionKey in props.collections) {
const collection = props.collections[collectionKey]!
if (collection.fields?.length) {
schemaMap.set(collectionKey, {
fields: collection.fields,
})
}
}

View File

@@ -8,6 +8,7 @@ import {
FieldLabel,
RenderCustomComponent,
useEditDepth,
useEffectEvent,
useField,
} from '@payloadcms/ui'
import { mergeFieldStyles } from '@payloadcms/ui/shared'
@@ -15,11 +16,13 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import type { SanitizedClientEditorConfig } from '../lexical/config/types.js'
import type { LexicalRichTextFieldProps } from '../types.js'
import '../lexical/theme/EditorTheme.scss'
import './bundled.css'
import './index.scss'
import type { LexicalRichTextFieldProps } from '../types.js'
import { LexicalProvider } from '../lexical/LexicalProvider.js'
const baseClass = 'rich-text-lexical'
@@ -126,14 +129,30 @@ const RichTextComponent: React.FC<
const styles = useMemo(() => mergeFieldStyles(field), [field])
useEffect(() => {
if (JSON.stringify(initialValue) !== JSON.stringify(prevInitialValueRef.current)) {
prevInitialValueRef.current = initialValue
if (JSON.stringify(prevValueRef.current) !== JSON.stringify(value)) {
const handleInitialValueChange = useEffectEvent(
(initialValue: SerializedEditorState | undefined) => {
// Object deep equality check here, as re-mounting the editor if
// the new value is the same as the old one is not necessary
if (
prevValueRef.current !== value &&
JSON.stringify(prevValueRef.current) !== JSON.stringify(value)
) {
prevInitialValueRef.current = initialValue
prevValueRef.current = value
setRerenderProviderKey(new Date())
}
},
)
useEffect(() => {
// Needs to trigger for object reference changes - otherwise,
// reacting to the same initial value change twice will cause
// the second change to be ignored, even though the value has changed.
// That's because initialValue is not kept up-to-date
if (!Object.is(initialValue, prevInitialValueRef.current)) {
handleInitialValueChange(initialValue)
}
}, [initialValue, value])
}, [initialValue])
return (
<div className={classes} key={pathWithEditDepth} style={styles}>

View File

@@ -2,7 +2,7 @@
import type { RichTextFieldClient } from 'payload'
import { ShimmerEffect } from '@payloadcms/ui'
import { ShimmerEffect, useConfig } from '@payloadcms/ui'
import React, { lazy, Suspense, useEffect, useState } from 'react'
import type { FeatureProviderClient } from '../features/typesClient.js'
@@ -27,6 +27,8 @@ export const RichTextField: React.FC<LexicalRichTextFieldProps> = (props) => {
schemaPath,
} = props
const { config } = useConfig()
const [finalSanitizedEditorConfig, setFinalSanitizedEditorConfig] =
useState<null | SanitizedClientEditorConfig>(null)
@@ -50,6 +52,7 @@ export const RichTextField: React.FC<LexicalRichTextFieldProps> = (props) => {
: defaultEditorLexicalConfig
const resolvedClientFeatures = loadClientFeatures({
config,
featureClientSchemaMap,
field: field as RichTextFieldClient,
schemaPath: schemaPath ?? field.name,
@@ -69,6 +72,7 @@ export const RichTextField: React.FC<LexicalRichTextFieldProps> = (props) => {
clientFeatures,
featureClientSchemaMap,
field,
config,
schemaPath,
]) // TODO: Optimize this and use useMemo for this in the future. This might break sub-richtext-blocks from the blocks feature. Need to investigate

View File

@@ -268,7 +268,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
originalNode: originalNodeIDMap[id],
parentRichTextFieldPath: path,
parentRichTextFieldSchemaPath: schemaPath,
previousNode: previousNodeIDMap[id],
previousNode: previousNodeIDMap[id]!,
req,
})
}
@@ -281,9 +281,9 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
if (subFieldFn && subFieldDataFn) {
const subFields = subFieldFn({ node, req })
const nodeSiblingData = subFieldDataFn({ node, req }) ?? {}
const nodeSiblingDoc = subFieldDataFn({ node: originalNodeIDMap[id], req }) ?? {}
const nodeSiblingDoc = subFieldDataFn({ node: originalNodeIDMap[id]!, req }) ?? {}
const nodePreviousSiblingDoc =
subFieldDataFn({ node: previousNodeIDMap[id], req }) ?? {}
subFieldDataFn({ node: previousNodeIDMap[id]!, req }) ?? {}
if (subFields?.length) {
await afterChangeTraverseFields({
@@ -540,7 +540,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
originalNodeWithLocales: originalNodeWithLocalesIDMap[id],
parentRichTextFieldPath: path,
parentRichTextFieldSchemaPath: schemaPath,
previousNode: previousNodeIDMap[id],
previousNode: previousNodeIDMap[id]!,
req,
skipValidation: skipValidation!,
})
@@ -557,11 +557,11 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
const nodeSiblingData = subFieldDataFn({ node, req }) ?? {}
const nodeSiblingDocWithLocales =
subFieldDataFn({
node: originalNodeWithLocalesIDMap[id],
node: originalNodeWithLocalesIDMap[id]!,
req,
}) ?? {}
const nodePreviousSiblingDoc =
subFieldDataFn({ node: previousNodeIDMap[id], req }) ?? {}
subFieldDataFn({ node: previousNodeIDMap[id]!, req }) ?? {}
if (subFields?.length) {
await beforeChangeTraverseFields({
@@ -756,7 +756,7 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
if (subFieldFn && subFieldDataFn) {
const subFields = subFieldFn({ node, req })
const nodeSiblingData = subFieldDataFn({ node, req }) ?? {}
const nodeSiblingDoc = subFieldDataFn({ node: originalNodeIDMap[id], req }) ?? {}
const nodeSiblingDoc = subFieldDataFn({ node: originalNodeIDMap[id]!, req }) ?? {}
if (subFields?.length) {
await beforeValidateTraverseFields({

View File

@@ -1,6 +1,6 @@
'use client'
import type { RichTextFieldClient } from 'payload'
import type { ClientConfig, RichTextFieldClient } from 'payload'
import type {
ClientFeatureProviderMap,
@@ -15,11 +15,13 @@ import type { ClientEditorConfig } from '../types.js'
* @param unSanitizedEditorConfig
*/
export function loadClientFeatures({
config,
featureClientSchemaMap,
field,
schemaPath,
unSanitizedEditorConfig,
}: {
config: ClientConfig
featureClientSchemaMap: FeatureClientSchemaMap
field?: RichTextFieldClient
schemaPath: string
@@ -55,6 +57,7 @@ export function loadClientFeatures({
const feature: Partial<ResolvedClientFeature<any>> =
typeof featureProvider.feature === 'function'
? featureProvider.feature({
config,
featureClientSchemaMap,
featureProviderMap,
field,

View File

@@ -1,20 +1,29 @@
'use client'
import type { DecoratorNode } from 'lexical'
import type { DecoratorNode, ElementNode, LexicalNode } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import {
$createNodeSelection,
$getEditor,
$getNearestNodeFromDOMNode,
$getSelection,
$isDecoratorNode,
$isElementNode,
$isLineBreakNode,
$isNodeSelection,
$isRangeSelection,
$isRootOrShadowRoot,
$isTextNode,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical'
import { useEffect } from 'react'
@@ -43,11 +52,10 @@ export function DecoratorPlugin() {
CLICK_COMMAND,
(event) => {
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
const decorator = $getDecorator(event)
const decorator = $getDecoratorByMouseEvent(event)
if (!decorator) {
return true
}
const { decoratorElement, decoratorNode } = decorator
const { target } = event
const isInteractive =
!(target instanceof HTMLElement) ||
@@ -58,10 +66,7 @@ export function DecoratorPlugin() {
if (isInteractive) {
$setSelection(null)
} else {
const selection = $createNodeSelection()
selection.add(decoratorNode.getKey())
$setSelection(selection)
decoratorElement.classList.add('decorator-selected')
$selectDecorator(decorator)
}
return true
},
@@ -69,22 +74,231 @@ export function DecoratorPlugin() {
),
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
const decorator = $getSelectedDecorator()
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
if (decorator) {
decorator.element?.classList.add('decorator-selected')
return true
}
return false
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_UP_COMMAND,
(event) => {
// CASE 1: Node selection
const selection = $getSelection()
if ($isNodeSelection(selection)) {
const prevSibling = selection.getNodes()[0]?.getPreviousSibling()
if ($isDecoratorNode(prevSibling)) {
const element = $getEditor().getElementByKey(prevSibling.getKey())
if (element) {
$selectDecorator({ element, node: prevSibling })
event.preventDefault()
return true
}
return false
}
if (!$isElementNode(prevSibling)) {
return false
}
const lastDescendant = prevSibling.getLastDescendant() ?? prevSibling
if (!lastDescendant) {
return false
}
const block = $findMatchingParent(lastDescendant, INTERNAL_$isBlock)
block?.selectStart()
event.preventDefault()
return true
}
if (!$isRangeSelection(selection)) {
return false
}
// CASE 2: Range selection
// Get first selected block
const firstPoint = selection.isBackward() ? selection.anchor : selection.focus
const firstNode = firstPoint.getNode()
const firstSelectedBlock = $findMatchingParent(firstNode, (node) => {
return findFirstSiblingBlock(node) !== null
})
const prevBlock = firstSelectedBlock?.getPreviousSibling()
if (!firstSelectedBlock || prevBlock !== findFirstSiblingBlock(firstSelectedBlock)) {
return false
}
if ($isDecoratorNode(prevBlock)) {
const prevBlockElement = $getEditor().getElementByKey(prevBlock.getKey())
if (prevBlockElement) {
$selectDecorator({ element: prevBlockElement, node: prevBlock })
event.preventDefault()
return true
}
}
return false
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
(event) => {
// CASE 1: Node selection
const selection = $getSelection()
if ($isNodeSelection(selection)) {
event.preventDefault()
const nextSibling = selection.getNodes()[0]?.getNextSibling()
if ($isDecoratorNode(nextSibling)) {
const element = $getEditor().getElementByKey(nextSibling.getKey())
if (element) {
$selectDecorator({ element, node: nextSibling })
}
return true
}
if (!$isElementNode(nextSibling)) {
return true
}
const firstDescendant = nextSibling.getFirstDescendant() ?? nextSibling
if (!firstDescendant) {
return true
}
const block = $findMatchingParent(firstDescendant, INTERNAL_$isBlock)
block?.selectEnd()
event.preventDefault()
return true
}
if (!$isRangeSelection(selection)) {
return false
}
// CASE 2: Range selection
// Get last selected block
const lastPoint = selection.isBackward() ? selection.anchor : selection.focus
const lastNode = lastPoint.getNode()
const lastSelectedBlock = $findMatchingParent(lastNode, (node) => {
return findLaterSiblingBlock(node) !== null
})
const nextBlock = lastSelectedBlock?.getNextSibling()
if (!lastSelectedBlock || nextBlock !== findLaterSiblingBlock(lastSelectedBlock)) {
return false
}
if ($isDecoratorNode(nextBlock)) {
const nextBlockElement = $getEditor().getElementByKey(nextBlock.getKey())
if (nextBlockElement) {
$selectDecorator({ element: nextBlockElement, node: nextBlock })
event.preventDefault()
return true
}
}
return false
},
COMMAND_PRIORITY_LOW,
),
)
}, [editor])
return null
}
function $getDecorator(
function $getDecoratorByMouseEvent(
event: MouseEvent,
): { decoratorElement: Element; decoratorNode: DecoratorNode<unknown> } | undefined {
if (!(event.target instanceof Element)) {
): { element: HTMLElement; node: DecoratorNode<unknown> } | undefined {
if (!(event.target instanceof HTMLElement)) {
return undefined
}
const decoratorElement = event.target.closest('[data-lexical-decorator="true"]')
if (!decoratorElement) {
const element = event.target.closest('[data-lexical-decorator="true"]')
if (!(element instanceof HTMLElement)) {
return undefined
}
const node = $getNearestNodeFromDOMNode(decoratorElement)
return $isDecoratorNode(node) ? { decoratorElement, decoratorNode: node } : undefined
const node = $getNearestNodeFromDOMNode(element)
return $isDecoratorNode(node) ? { element, node } : undefined
}
function $getSelectedDecorator() {
const selection = $getSelection()
if (!$isNodeSelection(selection)) {
return undefined
}
const nodes = selection.getNodes()
if (nodes.length !== 1) {
return undefined
}
const node = nodes[0]
return $isDecoratorNode(node)
? {
decorator: node,
element: $getEditor().getElementByKey(node.getKey()),
}
: undefined
}
function $selectDecorator({
element,
node,
}: {
element: HTMLElement
node: DecoratorNode<unknown>
}) {
document.querySelector('.decorator-selected')?.classList.remove('decorator-selected')
const selection = $createNodeSelection()
selection.add(node.getKey())
$setSelection(selection)
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
element.classList.add('decorator-selected')
}
/**
* Copied from https://github.com/facebook/lexical/blob/main/packages/lexical/src/LexicalUtils.ts
*
* This function returns true for a DecoratorNode that is not inline OR
* an ElementNode that is:
* - not a root or shadow root
* - not inline
* - can't be empty
* - has no children or an inline first child
*/
export function INTERNAL_$isBlock(node: LexicalNode): node is DecoratorNode<unknown> | ElementNode {
if ($isDecoratorNode(node) && !node.isInline()) {
return true
}
if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
return false
}
const firstChild = node.getFirstChild()
const isLeafElement =
firstChild === null ||
$isLineBreakNode(firstChild) ||
$isTextNode(firstChild) ||
firstChild.isInline()
return !node.isInline() && node.canBeEmpty() !== false && isLeafElement
}
function findLaterSiblingBlock(node: LexicalNode): LexicalNode | null {
let current = node.getNextSibling()
while (current !== null) {
if (INTERNAL_$isBlock(current)) {
return current
}
current = current.getNextSibling()
}
return null
}
function findFirstSiblingBlock(node: LexicalNode): LexicalNode | null {
let current = node.getPreviousSibling()
while (current !== null) {
if (INTERNAL_$isBlock(current)) {
return current
}
current = current.getPreviousSibling()
}
return null
}

View File

@@ -82,18 +82,18 @@ function getFullMatchOffset(documentText: string, entryText: string, offset: num
* Split Lexical TextNode and return a new TextNode only containing matched text.
* Common use cases include: removing the node, replacing with a new node.
*/
function $splitNodeContainingQuery(match: MenuTextMatch): null | TextNode {
function $splitNodeContainingQuery(match: MenuTextMatch): TextNode | undefined {
const selection = $getSelection()
if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
return null
return
}
const anchor = selection.anchor
if (anchor.type !== 'text') {
return null
return
}
const anchorNode = anchor.getNode()
if (!anchorNode.isSimpleText()) {
return null
return
}
const selectionOffset = anchor.offset
const textContent = anchorNode.getTextContent().slice(0, selectionOffset)
@@ -101,7 +101,7 @@ function $splitNodeContainingQuery(match: MenuTextMatch): null | TextNode {
const queryOffset = getFullMatchOffset(textContent, match.matchingString, characterOffset)
const startOffset = selectionOffset - queryOffset
if (startOffset < 0) {
return null
return
}
let newNode
if (startOffset === 0) {
@@ -241,7 +241,7 @@ export function LexicalMenu({
const allItems = groups.flatMap((group) => group.items)
if (allItems.length) {
const firstMatchingItem = allItems[0]
const firstMatchingItem = allItems[0]!
updateSelectedItem(firstMatchingItem)
}
}
@@ -334,6 +334,9 @@ export function LexicalMenu({
const newSelectedIndex = selectedIndex !== allItems.length - 1 ? selectedIndex + 1 : 0
const newSelectedItem = allItems[newSelectedIndex]
if (!newSelectedItem) {
return false
}
updateSelectedItem(newSelectedItem)
if (newSelectedItem.ref != null && newSelectedItem.ref.current) {
@@ -360,6 +363,9 @@ export function LexicalMenu({
const newSelectedIndex = selectedIndex !== 0 ? selectedIndex - 1 : allItems.length - 1
const newSelectedItem = allItems[newSelectedIndex]
if (!newSelectedItem) {
return false
}
updateSelectedItem(newSelectedItem)
if (newSelectedItem.ref != null && newSelectedItem.ref.current) {

View File

@@ -47,18 +47,18 @@ export function useMenuTriggerMatch(
)
const match = TypeaheadTriggerRegex.exec(query)
if (match !== null) {
const maybeLeadingWhitespace = match[1]
const maybeLeadingWhitespace = match[1]!
/**
* matchingString is only the text AFTER the trigger text. (So everything after the /)
*/
const matchingString = match[3]
const matchingString = match[3]!
if (matchingString.length >= minLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2], // replaceableString is the trigger text + the matching string
replaceableString: match[2]!, // replaceableString is the trigger text + the matching string
}
}
}

View File

@@ -98,10 +98,8 @@ export function getNodeCloseToPoint(props: Props): Output {
// Return null if matching block element is the first or last node
editor.getEditorState().read(() => {
if (useEdgeAsDefault) {
const [firstNode, lastNode] = [
editor.getElementByKey(topLevelNodeKeys[0]),
editor.getElementByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1]),
]
const firstNode = editor.getElementByKey(topLevelNodeKeys[0]!)
const lastNode = editor.getElementByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1]!)
if (firstNode && lastNode) {
const [firstNodeRect, lastNodeRect] = [
@@ -112,11 +110,11 @@ export function getNodeCloseToPoint(props: Props): Output {
if (y < firstNodeRect.top) {
closestBlockElem.blockElem = firstNode
closestBlockElem.distance = firstNodeRect.top - y
closestBlockElem.blockNode = $getNodeByKey(topLevelNodeKeys[0])
closestBlockElem.blockNode = $getNodeByKey(topLevelNodeKeys[0]!)
closestBlockElem.foundAtIndex = 0
} else if (y > lastNodeRect.bottom) {
closestBlockElem.distance = y - lastNodeRect.bottom
closestBlockElem.blockNode = $getNodeByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1])
closestBlockElem.blockNode = $getNodeByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1]!)
closestBlockElem.blockElem = lastNode
closestBlockElem.foundAtIndex = topLevelNodeKeys.length - 1
}
@@ -135,7 +133,7 @@ export function getNodeCloseToPoint(props: Props): Output {
let direction = Indeterminate
while (index >= 0 && index < topLevelNodeKeys.length) {
const key = topLevelNodeKeys[index]
const key = topLevelNodeKeys[index]!
const elem = editor.getElementByKey(key)
if (elem === null) {
break

View File

@@ -1,4 +1,4 @@
import type { RichTextFieldClient } from 'payload'
import type { ClientConfig, RichTextFieldClient } from 'payload'
import type {
BaseClientFeatureProps,
@@ -13,6 +13,7 @@ import type { FeatureClientSchemaMap } from '../types.js'
export type CreateClientFeatureArgs<UnSanitizedClientProps, ClientProps> =
| ((props: {
config: ClientConfig
featureClientSchemaMap: FeatureClientSchemaMap
/** unSanitizedEditorConfig.features, but mapped */
featureProviderMap: ClientFeatureProviderMap
@@ -39,6 +40,7 @@ export const createClientFeature: <
if (typeof feature === 'function') {
featureProviderClient.feature = ({
config,
featureClientSchemaMap,
featureProviderMap,
field,
@@ -47,6 +49,7 @@ export const createClientFeature: <
unSanitizedEditorConfig,
}) => {
const toReturn = feature({
config,
featureClientSchemaMap,
featureProviderMap,
field,

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-azure",
"version": "3.21.0",
"version": "3.22.0",
"description": "Payload storage adapter for Azure Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-gcs",
"version": "3.21.0",
"version": "3.22.0",
"description": "Payload storage adapter for Google Cloud Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-s3",
"version": "3.21.0",
"version": "3.22.0",
"description": "Payload storage adapter for Amazon S3",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,7 @@
import type * as AWS from '@aws-sdk/client-s3'
import type { StaticHandler } from '@payloadcms/plugin-cloud-storage/types'
import type { CollectionConfig } from 'payload'
import type { Readable } from 'stream'
import { getFilePrefix } from '@payloadcms/plugin-cloud-storage/utilities'
import path from 'path'
@@ -11,6 +12,18 @@ interface Args {
getStorageClient: () => AWS.S3
}
// Type guard for NodeJS.Readable streams
const isNodeReadableStream = (body: unknown): body is Readable => {
return (
typeof body === 'object' &&
body !== null &&
'pipe' in body &&
typeof (body as any).pipe === 'function' &&
'destroy' in body &&
typeof (body as any).destroy === 'function'
)
}
// Convert a stream into a promise that resolves with a Buffer
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const streamToBuffer = async (readableStream: any) => {
@@ -26,9 +39,11 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat
try {
const prefix = await getFilePrefix({ collection, filename, req })
const key = path.posix.join(prefix, filename)
const object = await getStorageClient().getObject({
Bucket: bucket,
Key: path.posix.join(prefix, filename),
Key: key,
})
if (!object.Body) {
@@ -39,7 +54,7 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat
const objectEtag = object.ETag
if (etagFromHeaders && etagFromHeaders === objectEtag) {
return new Response(null, {
const response = new Response(null, {
headers: new Headers({
'Accept-Ranges': String(object.AcceptRanges),
'Content-Length': String(object.ContentLength),
@@ -48,6 +63,26 @@ export const getHandler = ({ bucket, collection, getStorageClient }: Args): Stat
}),
status: 304,
})
// Manually destroy stream before returning cached results to close socket
if (object.Body && isNodeReadableStream(object.Body)) {
object.Body.destroy()
}
return response
}
// On error, manually destroy stream to close socket
if (object.Body && isNodeReadableStream(object.Body)) {
const stream = object.Body
stream.on('error', (err) => {
req.payload.logger.error({
err,
key,
msg: 'Error streaming S3 object, destroying stream',
})
stream.destroy()
})
}
const bodyBuffer = await streamToBuffer(object.Body)

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-uploadthing",
"version": "3.21.0",
"version": "3.22.0",
"description": "Payload storage adapter for uploadthing",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/storage-vercel-blob",
"version": "3.21.0",
"version": "3.22.0",
"description": "Payload storage adapter for Vercel Blob Storage",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

View File

@@ -209,7 +209,6 @@ export const clientTranslationKeys = createClientTranslationKeys([
'general:loading',
'general:locale',
'general:menu',
'general:listControlsMenu',
'general:moveDown',
'general:moveUp',
'general:next',
@@ -266,6 +265,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
'general:takeOver',
'general:thisLanguage',
'general:time',
'general:timezone',
'general:titleDeleted',
'general:true',
'general:upcomingEvents',
@@ -341,6 +341,7 @@ export const clientTranslationKeys = createClientTranslationKeys([
'validation:requiresAtLeast',
'validation:shorterThanMax',
'validation:greaterThanMax',
'validation:timezoneRequired',
'validation:username',
'version:aboutToPublishSelection',

View File

@@ -261,7 +261,6 @@ export const arTranslations: DefaultTranslationsObject = {
leaveAnyway: 'المغادرة على أي حال',
leaveWithoutSaving: 'المغادرة بدون حفظ',
light: 'فاتح',
listControlsMenu: 'قائمة التحكم',
livePreview: 'معاينة مباشرة',
loading: 'يتمّ التّحميل',
locale: 'اللّغة',
@@ -327,6 +326,7 @@ export const arTranslations: DefaultTranslationsObject = {
takeOver: 'تولي',
thisLanguage: 'العربية',
time: 'الوقت',
timezone: 'المنطقة الزمنية',
titleDeleted: 'تم حذف {{label}} "{{title}}" بنجاح.',
true: 'صحيح',
unauthorized: 'غير مصرح به',
@@ -417,6 +417,7 @@ export const arTranslations: DefaultTranslationsObject = {
requiresNoMoreThan: 'هذا الحقل يتطلب عدم تجاوز {{count}} {{label}}.',
requiresTwoNumbers: 'هذا الحقل يتطلب رقمين.',
shorterThanMax: 'يجب أن تكون هذه القيمة أقصر من الحد الأقصى للطول الذي هو {{maxLength}} أحرف.',
timezoneRequired: 'مطلوب منطقة زمنية.',
trueOrFalse: 'يمكن أن يكون هذا الحقل مساويًا فقط للقيمتين صحيح أو خطأ.',
username:
'يرجى إدخال اسم مستخدم صالح. يمكن أن يحتوي على أحرف، أرقام، شرطات، فواصل وشرطات سفلية.',

View File

@@ -265,7 +265,6 @@ export const azTranslations: DefaultTranslationsObject = {
leaveAnyway: 'Heç olmasa çıx',
leaveWithoutSaving: 'Saxlamadan çıx',
light: 'Açıq',
listControlsMenu: 'Siyahı nəzarət menyusu',
livePreview: 'Öncədən baxış',
loading: 'Yüklənir',
locale: 'Lokal',
@@ -330,6 +329,7 @@ export const azTranslations: DefaultTranslationsObject = {
takeOver: 'Əvvəl',
thisLanguage: 'Azərbaycan dili',
time: 'Vaxt',
timezone: 'Saat qurşağı',
titleDeleted: '{{label}} "{{title}}" uğurla silindi.',
true: 'Doğru',
unauthorized: 'İcazəsiz',
@@ -424,6 +424,7 @@ export const azTranslations: DefaultTranslationsObject = {
requiresNoMoreThan: 'Bu sahə {{count}} {{label}}-dan çox olmamalıdır.',
requiresTwoNumbers: 'Bu sahə iki nömrə tələb edir.',
shorterThanMax: 'Bu dəyər {{maxLength}} simvoldan qısa olmalıdır.',
timezoneRequired: 'Vaxt zonası tələb olunur.',
trueOrFalse: 'Bu sahə yalnız doğru və ya yanlış ola bilər.',
username:
'Zəhmət olmasa, etibarlı bir istifadəçi adı daxil edin. Hərflər, rəqəmlər, tire, nöqtə və alt xəttlər ola bilər.',

View File

@@ -264,7 +264,6 @@ export const bgTranslations: DefaultTranslationsObject = {
leaveAnyway: 'Напусни въпреки това',
leaveWithoutSaving: 'Напусни без да запазиш',
light: 'Светла',
listControlsMenu: 'Меню за контрол на списъка',
livePreview: 'Предварителен преглед',
loading: 'Зарежда се',
locale: 'Локализация',
@@ -330,6 +329,7 @@ export const bgTranslations: DefaultTranslationsObject = {
takeOver: 'Поемане',
thisLanguage: 'Български',
time: 'Време',
timezone: 'Часова зона',
titleDeleted: '{{label}} "{{title}}" успешно изтрит.',
true: 'Вярно',
unauthorized: 'Неоторизиран',
@@ -424,6 +424,7 @@ export const bgTranslations: DefaultTranslationsObject = {
requiresTwoNumbers: 'Това поле изисква 2 числа.',
shorterThanMax:
'Тази стойност трябва да е по-малка от максималната стойност от {{maxLength}} символа.',
timezoneRequired: 'Изисква се часова зона.',
trueOrFalse: 'Това поле може да бъде само "true" или "false".',
username:
'Моля, въведете валидно потребителско име. Може да съдържа букви, цифри, тирета, точки и долни черти.',

View File

@@ -265,7 +265,6 @@ export const caTranslations: DefaultTranslationsObject = {
leaveAnyway: 'Deixa-ho de totes maneres',
leaveWithoutSaving: 'Deixa sense desar',
light: 'Clar',
listControlsMenu: 'Menú de control de llista',
livePreview: 'Previsualització en viu',
loading: 'Carregant',
locale: 'Idioma',
@@ -331,6 +330,7 @@ export const caTranslations: DefaultTranslationsObject = {
takeOver: 'Prendre el control',
thisLanguage: 'Catala',
time: 'Temps',
timezone: 'Fus horari',
titleDeleted: '{{label}} "{{title}}" eliminat correctament.',
true: 'Veritat',
unauthorized: 'No autoritzat',
@@ -425,6 +425,7 @@ export const caTranslations: DefaultTranslationsObject = {
requiresTwoNumbers: 'Aquest camp requereix dos números.',
shorterThanMax:
'Aquest valor ha de ser més curt que la longitud màxima de {{maxLength}} caràcters.',
timezoneRequired: 'Es requereix una zona horària.',
trueOrFalse: 'Aquest camp només pot ser igual a true o false.',
username:
"Si us plau, introdueix un nom d'usuari vàlid. Pot contenir lletres, números, guions, punts i guions baixos.",

View File

@@ -263,7 +263,6 @@ export const csTranslations: DefaultTranslationsObject = {
leaveAnyway: 'Přesto odejít',
leaveWithoutSaving: 'Odejít bez uložení',
light: 'Světlé',
listControlsMenu: 'Nabídka ovládání seznamu',
livePreview: 'Náhled',
loading: 'Načítání',
locale: 'Místní verze',
@@ -328,6 +327,7 @@ export const csTranslations: DefaultTranslationsObject = {
takeOver: 'Převzít',
thisLanguage: 'Čeština',
time: 'Čas',
timezone: 'Časové pásmo',
titleDeleted: '{{label}} "{{title}}" úspěšně smazáno.',
true: 'Pravda',
unauthorized: 'Neoprávněný',
@@ -420,6 +420,7 @@ export const csTranslations: DefaultTranslationsObject = {
requiresNoMoreThan: 'Toto pole vyžaduje ne více než {{count}} {{label}}.',
requiresTwoNumbers: 'Toto pole vyžaduje dvě čísla.',
shorterThanMax: 'Tato hodnota musí být kratší než maximální délka {{maxLength}} znaků.',
timezoneRequired: 'Je vyžadováno časové pásmo.',
trueOrFalse: 'Toto pole může být rovno pouze true nebo false.',
username:
'Prosím, zadejte platné uživatelské jméno. Může obsahovat písmena, čísla, pomlčky, tečky a podtržítka.',

View File

@@ -263,7 +263,6 @@ export const daTranslations: DefaultTranslationsObject = {
leaveAnyway: 'Forlad alligevel',
leaveWithoutSaving: 'Forlad uden at gemme',
light: 'Lys',
listControlsMenu: 'Kontrolmenu for liste',
livePreview: 'Live-forhåndsvisning',
loading: 'Loader',
locale: 'Lokalitet',
@@ -329,6 +328,7 @@ export const daTranslations: DefaultTranslationsObject = {
takeOver: 'Overtag',
thisLanguage: 'Dansk',
time: 'Tid',
timezone: 'Tidszone',
titleDeleted: '{{label}} "{{title}}" slettet.',
true: 'Sandt',
unauthorized: 'Uautoriseret',
@@ -421,6 +421,7 @@ export const daTranslations: DefaultTranslationsObject = {
requiresNoMoreThan: 'Dette felt kræver maks {{count}} {{label}}.',
requiresTwoNumbers: 'Dette felt kræver to numre.',
shorterThanMax: 'Denne værdi skal være kortere end den maksimale længde af {{maxLength}} tegn.',
timezoneRequired: 'En tidszone er nødvendig.',
trueOrFalse: 'Denne værdi kan kun være lig med sandt eller falsk.',
username:
'Indtast et brugernavn. Kan indeholde bogstaver, tal, bindestreger, punktum og underscores.',

View File

@@ -269,7 +269,6 @@ export const deTranslations: DefaultTranslationsObject = {
leaveAnyway: 'Trotzdem verlassen',
leaveWithoutSaving: 'Ohne speichern verlassen',
light: 'Hell',
listControlsMenu: 'Kontrollmenü auflisten',
livePreview: 'Vorschau',
loading: 'Lädt',
locale: 'Sprache',
@@ -334,6 +333,7 @@ export const deTranslations: DefaultTranslationsObject = {
takeOver: 'Übernehmen',
thisLanguage: 'Deutsch',
time: 'Zeit',
timezone: 'Zeitzone',
titleDeleted: '{{label}} {{title}} wurde erfolgreich gelöscht.',
true: 'Wahr',
unauthorized: 'Nicht autorisiert',
@@ -428,6 +428,7 @@ export const deTranslations: DefaultTranslationsObject = {
requiresNoMoreThan: 'Dieses Feld kann nicht mehr als {{count}} {{label}} enthalten.',
requiresTwoNumbers: 'Dieses Feld muss zwei Nummern enthalten.',
shorterThanMax: 'Dieser Wert muss kürzer als die maximale Länge von {{maxLength}} sein.',
timezoneRequired: 'Eine Zeitzone ist erforderlich.',
trueOrFalse: 'Dieses Feld kann nur wahr oder falsch sein.',
username:
'Bitte geben Sie einen gültigen Benutzernamen ein. Dieser kann Buchstaben, Zahlen, Bindestriche, Punkte und Unterstriche enthalten.',

View File

@@ -265,7 +265,6 @@ export const enTranslations = {
leaveAnyway: 'Leave anyway',
leaveWithoutSaving: 'Leave without saving',
light: 'Light',
listControlsMenu: 'List control menu',
livePreview: 'Live Preview',
loading: 'Loading',
locale: 'Locale',
@@ -331,6 +330,7 @@ export const enTranslations = {
takeOver: 'Take over',
thisLanguage: 'English',
time: 'Time',
timezone: 'Timezone',
titleDeleted: '{{label}} "{{title}}" successfully deleted.',
true: 'True',
unauthorized: 'Unauthorized',
@@ -423,6 +423,7 @@ export const enTranslations = {
requiresNoMoreThan: 'This field requires no more than {{count}} {{label}}.',
requiresTwoNumbers: 'This field requires two numbers.',
shorterThanMax: 'This value must be shorter than the max length of {{maxLength}} characters.',
timezoneRequired: 'A timezone is required.',
trueOrFalse: 'This field can only be equal to true or false.',
username:
'Please enter a valid username. Can contain letters, numbers, hyphens, periods and underscores.',

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