Compare commits
34 Commits
chore/list
...
fix/blocks
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d1c4d5dc4 | ||
|
|
d6a03eeaba | ||
|
|
3f80c5993c | ||
|
|
c18c58e1fb | ||
|
|
36168184b5 | ||
|
|
98fec35368 | ||
|
|
fde526e07f | ||
|
|
5dadccea39 | ||
|
|
d2fe9b0807 | ||
|
|
95ec57575d | ||
|
|
430ebd42ff | ||
|
|
3415ba81ac | ||
|
|
fa18923317 | ||
|
|
91a0f90649 | ||
|
|
b15a7e3c72 | ||
|
|
d56de79671 | ||
|
|
87ba7f77aa | ||
|
|
9fb7160c2c | ||
|
|
c6c65ac842 | ||
|
|
dc56acbdaf | ||
|
|
6a99677d15 | ||
|
|
6d48cf9bbf | ||
|
|
d7a7fbf93a | ||
|
|
5a5385423e | ||
|
|
ac6f4e2c86 | ||
|
|
886bd94fc3 | ||
|
|
a80c6b5212 | ||
|
|
6f53747040 | ||
|
|
b820a75ec5 | ||
|
|
49d94d53e0 | ||
|
|
feea444867 | ||
|
|
257cad71ce | ||
|
|
04dad9d7a6 | ||
|
|
098fe10ade |
21
.github/CODEOWNERS
vendored
21
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
| --------------------------- | -------------------------------------------------------------------------------- |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export const defaultESLintIgnores = [
|
||||
'**/build/',
|
||||
'**/node_modules/',
|
||||
'**/temp/',
|
||||
'**/*.spec.ts',
|
||||
'**/packages/*.spec.ts',
|
||||
'next-env.d.ts',
|
||||
'**/app',
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-payload-app",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { parseParams } from './parseParams.js'
|
||||
|
||||
export type BuildQueryJoinAliases = {
|
||||
condition: SQL
|
||||
queryPath?: string
|
||||
table: GenericTable | PgTableWithColumns<any>
|
||||
type?: 'innerJoin' | 'leftJoin' | 'rightJoin'
|
||||
}[]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/graphql",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/next",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
19
packages/payload/src/fields/baseFields/timezone/baseField.ts
Normal file
19
packages/payload/src/fields/baseFields/timezone/baseField.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
]
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-redirects",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"description": "Redirects plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-search",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"description": "Search plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-sentry",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"description": "Sentry plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-seo",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"description": "SEO plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/plugin-stripe",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"description": "Stripe plugin for Payload",
|
||||
"keywords": [
|
||||
"payload",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -85,7 +85,7 @@ function ToolbarGroupComponent({
|
||||
}
|
||||
return
|
||||
}
|
||||
const item = activeItems[0]
|
||||
const item = activeItems[0]!
|
||||
|
||||
let label = item.key
|
||||
if (item.label) {
|
||||
|
||||
@@ -86,7 +86,7 @@ function ToolbarGroupComponent({
|
||||
return
|
||||
}
|
||||
const item = activeItems[0]
|
||||
setDropdownIcon(() => item.ChildComponent)
|
||||
setDropdownIcon(() => item?.ChildComponent)
|
||||
},
|
||||
[group],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/translations",
|
||||
"version": "3.21.0",
|
||||
"version": "3.22.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
'يرجى إدخال اسم مستخدم صالح. يمكن أن يحتوي على أحرف، أرقام، شرطات، فواصل وشرطات سفلية.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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:
|
||||
'Моля, въведете валидно потребителско име. Може да съдържа букви, цифри, тирета, точки и долни черти.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user