Compare commits

...

11 Commits

Author SHA1 Message Date
Elliot DeNolf
9ef51a7cf3 chore(release): v3.39.1 [skip ci] 2025-05-22 11:37:58 -04:00
Elliot DeNolf
0f7dc38012 fix: update folders export paths (#12501)
Fixes issues with folder exports after generating import map

Co-authored-by: Jarrod Flesch <jarrodmflesch@gmail.com>
2025-05-22 11:35:00 -04:00
Dan Ribbens
c720ce3c08 docs: folders beta (#12500) 2025-05-22 11:25:56 -04:00
Elliot DeNolf
3a73a67ef4 templates: include ui package (#12499)
Folder view needed to have `@payloadcms/ui` explicitly installed.
Including this in the templates.
2025-05-22 11:19:49 -04:00
Elliot DeNolf
4c6fde0e89 templates: bump for v3.39.0 (#12498)
🤖 Automated bump of templates for v3.39.0

Triggered by user: @denolfe

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-22 10:32:52 -04:00
Elliot DeNolf
c1c0db3b01 chore(release): v3.39.0 [skip ci] 2025-05-22 10:18:04 -04:00
Jarrod Flesch
00667faf8d feat: folders (#10030) 2025-05-22 10:04:45 -04:00
Paul
898e97ed17 fix(cpa): ensure it always installs the latest version of the templates (#12488)
CPA would previously install an outdated version of the templates based
on the git tag, this is now set to the `main` branch ensuring that the
latest version is always installed.
2025-05-22 09:55:42 -04:00
Jarrod Flesch
8142a00da6 chore: simplifies buildColumnState functions (#12496) 2025-05-22 09:54:50 -04:00
Anders Semb Hermansen
08a3dfbbcb chore(live-preview): load schemaJSON from proper client config in integration tests (#12167)
### What?

The integration tests in live-preview has been using the
`fieldSchemaToJSON` method with wrong params/types.

It's defined as
```
export const fieldSchemaToJSON = (fields: ClientField[], config: ClientConfig): FieldSchemaJSON
```

In the test setup
`fields` was set to `Pages.fields` which was `Field[]`, not the expected
`ClientField[]`
`config` was set to `config` which was `Promise<SanitizedConfig>` not
the expected `ClientConfig`

### Why?

I'm working on some other changes to live-preview where I need the
proper values wired up correctly to properly add integration tests.

The test has worked up until now because Field is very similar to
ClientField. But it should test with the correct type.

### How?

By creating the clientConfig and using the correct types/params when
calling fieldSchemaToJSON in the test setup.

**Note:** Removed test "Collections - Live Preview › merges data", the
test worked before because **id** field is not part of Field, but part
of ClientField. So test code does not behave like this in real scenario
when real ClientField is used. There are lots of real tests for correct
data, removed this one which seems very simple and not correct.
2025-05-22 07:44:03 -03:00
Germán Jabloñski
fc83823e5d feat(richtext-lexical): add TextStateFeature (allows applying styles such as color and background color to text) (#9667)
Originally this PR was going to introduce a `TextColorFeature`, but it
ended up becoming a more general-purpose `TextStateFeature`.

## Example of use:
```ts
import { defaultColors, TextStateFeature } from '@payloadcms/richtext-lexical'

TextStateFeature({
  // prettier-ignore
  state: {
    color: {
      ...defaultColors,
      // fancy gradients!
      galaxy: { label: 'Galaxy', css: { background: 'linear-gradient(to right, #0000ff, #ff0000)', color: 'white' } },
      sunset: { label: 'Sunset', css: { background: 'linear-gradient(to top, #ff5f6d, #6a3093)' } },
    },
    // You can have both colored and underlined text at the same time. 
    // If you don't want that, you should group them within the same key.
    // (just like I did with defaultColors and my fancy gradients)
    underline: {
      'solid': { label: 'Solid', css: { 'text-decoration': 'underline', 'text-underline-offset': '4px' } },
       // You'll probably want to use the CSS light-dark() utility.
      'yellow-dashed': { label: 'Yellow Dashed', css: { 'text-decoration': 'underline dashed', 'text-decoration-color': 'light-dark(#EAB308,yellow)', 'text-underline-offset': '4px' } },
    },
  },
}),

```

Which will result in the following:


![image](https://github.com/user-attachments/assets/ed29b30b-8efd-4265-a1b9-125c97ac5fce)


## Challenges & Considerations
Adding colors or styles in general to the Lexical editor is not as
simple as it seems.

1. **Extending TextNode isn't ideal**
- While possible, it's verbose, error-prone, and not composable. If
multiple features extend the same node, conflicts arise.
- That’s why we collaborated with the Lexical team to introduce [the new
State API](https://lexical.dev/docs/concepts/node-replacement)
([PR](https://github.com/facebook/lexical/pull/7117)).
2. **Issues with patchStyles**
- Some community plugins use `patchStyles`, but storing CSS in the
editor’s JSON has drawbacks:
- Style adaptability: Users may want different styles per scenario
(dark/light mode, mobile/web, etc.).
- Migration challenges: Hardcoded colors (e.g., #FF0000) make updates
difficult. Using tokens (e.g., "red") allows flexibility.
      - Larger JSON footprint increases DB size.
3. **Managing overlapping styles**
- Some users may want both text and background colors on the same node,
while others may prefer mutual exclusivity.
    - This approach allows either:
        - Using a single "color" state (e.g., "bg-red" + "text-red").
- Defining separate "bg-color" and "text-color" states for independent
styling.
4. **Good light and dark modes by default**
- Many major editors (Google Docs, OneNote, Word) treat dark mode as an
afterthought, leading to poor UX.
- We provide a well-balanced default palette that looks great in both
themes, serving as a strong foundation for customization.
5. **Feature name. Why TextState?**
- Other names considered were `TextFormatFeature` and
`TextStylesFeature`. The term `format` in Lexical and Payload is already
used to refer to something else (italic, bold, etc.). The term `style`
could be misleading since it is never attached to the editorState.
    - State seems appropriate because:
      - Lexical's new state API is used under the hood.
- Perhaps in the future we'll want to make state features for other
nodes, such as `ElementStateFeature` or `RootStateFeature`.

Note: There's a bug in Lexical's `forEachSelectedTextNode`. When the
selection includes a textNode partially on the left, all state for that
node is removed instead of splitting it along the selection edge.
2025-05-21 23:58:17 +00:00
370 changed files with 14617 additions and 2491 deletions

7
.vscode/launch.json vendored
View File

@@ -118,6 +118,13 @@
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts folder-view",
"cwd": "${workspaceFolder}",
"name": "Run Dev Folder View",
"request": "launch",
"type": "node-terminal"
},
{
"command": "pnpm tsx --no-deprecation test/dev.ts localization",
"cwd": "${workspaceFolder}",

View File

@@ -132,6 +132,7 @@ The following options are available:
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this Collection. |
| `enableRichTextLink` | The [Rich Text](../fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `enableRichTextRelationship` | The [Rich Text](../fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
| `folders` | A boolean to enable folders for a given collection. Defaults to `false`. [More details](../folders/overview). |
| `meta` | Page metadata overrides to apply to this Collection within the Admin Panel. [More details](../admin/metadata). |
| `preview` | Function to generate preview URLs within the Admin Panel that can point to your app. [More details](../admin/preview). |
| `livePreview` | Enable real-time editing for instant visual feedback of your front-end application. [More details](../live-preview/overview). |

View File

@@ -84,6 +84,7 @@ The following options are available:
| **`csrf`** | A whitelist array of URLs to allow Payload to accept cookies from. [More details](../authentication/cookies#csrf-attacks). |
| **`defaultDepth`** | If a user does not specify `depth` while requesting a resource, this depth will be used. [More details](../queries/depth). |
| **`defaultMaxTextLength`** | The maximum allowed string length to be permitted application-wide. Helps to prevent malicious public document creation. |
| `folders` | An optional object to configure global folder settings. [More details](../folders/overview). |
| `queryPresets` | An object that to configure Collection Query Presets. [More details](../query-presets/overview). |
| **`maxDepth`** | The maximum allowed depth to be permitted application-wide. This setting helps prevent against malicious queries. Defaults to `10`. [More details](../queries/depth). |
| **`indexSortableFields`** | Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. |

105
docs/folders/overview.mdx Normal file
View File

@@ -0,0 +1,105 @@
---
title: Folders
label: Folders
order: 10
desc: Folders allow you to group documents across collections, and are a great way to organize your content.
keywords: folders, folder, content organization
---
Folders allow you to group documents across collections, and are a great way to organize your content. Folders are built on top of relationship fields, when you enable folders on a collection, Payload adds a hidden relationship field `folders`, that relates to a folder — or no folder. Folders also have the `folder` field, allowing folders to be nested within other folders.
The configuration for folders is done in two places, the collection config and the Payload config. The collection config is where you enable folders, and the Payload config is where you configure the global folder settings.
<Banner type="warning">
**Note:** The Folders feature is currently in beta and may be subject to
change in minor versions updates prior to being stable.
</Banner>
## Folder Configuration
On the payload config, you can configure the following settings under the `folders` property:
```ts
// Type definition
type RootFoldersConfiguration = {
/**
* An array of functions to be ran when the folder collection is initialized
* This allows plugins to modify the collection configuration
*/
collectionOverrides?: (({
collection,
}: {
collection: CollectionConfig
}) => CollectionConfig | Promise<CollectionConfig>)[]
/**
* Ability to view hidden fields and collections related to folders
*
* @default false
*/
debug?: boolean
/**
* The Folder field name
*
* @default "folder"
*/
fieldName?: string
/**
* Slug for the folder collection
*
* @default "payload-folders"
*/
slug?: string
}
```
```ts
// Example usage
import { buildConfig } from 'payload'
const config = buildConfig({
// ...
folders: {
// highlight-start
debug: true, // optional
collectionOverrides: [
async ({ collection }) => {
return collection
},
], // optional
fieldName: 'folder', // optional
slug: 'payload-folders', // optional
// highlight-end
},
})
```
## Collection Configuration
To enable folders on a collection, you need to set the `admin.folders` property to `true` on the collection config. This will add a hidden relationship field to the collection that relates to a folder — or no folder.
```ts
// Type definition
type CollectionFoldersConfiguration = boolean
```
```ts
// Example usage
import { buildConfig } from 'payload'
const config = buildConfig({
collections: [
{
slug: 'pages',
// highlight-start
admin: {
folders: true, // defaults to false
},
// highlight-end
},
],
})
```

View File

@@ -81,7 +81,7 @@ To install a Database Adapter, you can run **one** of the following commands:
#### 2. Copy Payload files into your Next.js app folder
Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this:
Payload installs directly in your Next.js `/app` folder, and you'll need to place some files into that folder for Payload to run. You can copy these files from the [Blank Template](<https://github.com/payloadcms/payload/tree/main/templates/blank/src/app/(payload)>) on GitHub. Once you have the required Payload files in place in your `/app` folder, you should have something like this:
```plaintext
app/

View File

@@ -142,32 +142,33 @@ import { CallToAction } from '../blocks/CallToAction'
Here's an overview of all the included features:
| Feature Name | Included by default | Description |
| ------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`BoldFeature`** | Yes | Handles the bold text format |
| **`ItalicFeature`** | Yes | Handles the italic text format |
| **`UnderlineFeature`** | Yes | Handles the underline text format |
| **`StrikethroughFeature`** | Yes | Handles the strikethrough text format |
| **`SubscriptFeature`** | Yes | Handles the subscript text format |
| **`SuperscriptFeature`** | Yes | Handles the superscript text format |
| **`InlineCodeFeature`** | Yes | Handles the inline-code text format |
| **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs |
| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
| **`AlignFeature`** | Yes | Allows you to align text left, centered and right |
| **`IndentFeature`** | Yes | Allows you to indent text with the tab key |
| **`UnorderedListFeature`** | Yes | Adds unordered lists (ul) |
| **`OrderedListFeature`** | Yes | Adds ordered lists (ol) |
| **`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 |
| **`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 |
| **`FixedToolbarFeature`** | No | This classic toolbar is pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. |
| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](../fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
| **`TreeViewFeature`** | No | Adds a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging |
| **`EXPERIMENTAL_TableFeature`** | No | Adds support for tables. This feature may be removed or receive breaking changes in the future - even within a stable lexical release, without needing a major release. |
| Feature Name | Included by default | Description |
| ----------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`BoldFeature`** | Yes | Handles the bold text format |
| **`ItalicFeature`** | Yes | Handles the italic text format |
| **`UnderlineFeature`** | Yes | Handles the underline text format |
| **`StrikethroughFeature`** | Yes | Handles the strikethrough text format |
| **`SubscriptFeature`** | Yes | Handles the subscript text format |
| **`SuperscriptFeature`** | Yes | Handles the superscript text format |
| **`InlineCodeFeature`** | Yes | Handles the inline-code text format |
| **`ParagraphFeature`** | Yes | Handles paragraphs. Since they are already a key feature of lexical itself, this Feature mainly handles the Slash and Add-Block menu entries for paragraphs |
| **`HeadingFeature`** | Yes | Adds Heading Nodes (by default, H1 - H6, but that can be customized) |
| **`AlignFeature`** | Yes | Allows you to align text left, centered and right |
| **`IndentFeature`** | Yes | Allows you to indent text with the tab key |
| **`UnorderedListFeature`** | Yes | Adds unordered lists (ul) |
| **`OrderedListFeature`** | Yes | Adds ordered lists (ol) |
| **`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 |
| **`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 |
| **`FixedToolbarFeature`** | No | This classic toolbar is pinned to the top and always visible. Both inline and fixed toolbars can be enabled at the same time. |
| **`BlocksFeature`** | No | Allows you to use Payload's [Blocks Field](../fields/blocks) directly inside your editor. In the feature props, you can specify the allowed blocks - just like in the Blocks field. |
| **`TreeViewFeature`** | No | Adds a debug box under the editor, which allows you to see the current editor state live, the dom, as well as time travel. Very useful for debugging |
| **`EXPERIMENTAL_TableFeature`** | No | Adds support for tables. This feature may be removed or receive breaking changes in the future - even within a stable lexical release, without needing a major release. |
| **`EXPERIMENTAL_TextStateFeature`** | No | Allows you to store key-value attributes within TextNodes and assign them inline styles. |
Notice how even the toolbars are features? That's how extensible our lexical editor is - you could theoretically create your own toolbar if you wanted to!

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/admin-bar",
"version": "3.38.0",
"version": "3.39.1",
"description": "An admin bar for React apps using Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

View File

@@ -1,8 +0,0 @@
import { readFileSync } from 'fs'
import { fileURLToPath } from 'node:url'
import path from 'path'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const packageJson = JSON.parse(readFileSync(path.resolve(dirname, '../../package.json'), 'utf-8'))
export const PACKAGE_VERSION = packageJson.version

View File

@@ -1,9 +1,8 @@
import type { ProjectTemplate } from '../types.js'
import { error, info } from '../utils/log.js'
import { PACKAGE_VERSION } from './constants.js'
export function validateTemplate(templateName: string): boolean {
export function validateTemplate({ templateName }: { templateName: string }): boolean {
const validTemplates = getValidTemplates()
if (!validTemplates.map((t) => t.name).includes(templateName)) {
error(`'${templateName}' is not a valid template.`)
@@ -20,13 +19,13 @@ export function getValidTemplates(): ProjectTemplate[] {
name: 'blank',
type: 'starter',
description: 'Blank 3.0 Template',
url: `https://github.com/payloadcms/payload/templates/blank#v${PACKAGE_VERSION}`,
url: `https://github.com/payloadcms/payload/templates/blank#main`,
},
{
name: 'website',
type: 'starter',
description: 'Website Template',
url: `https://github.com/payloadcms/payload/templates/website#v${PACKAGE_VERSION}`,
url: `https://github.com/payloadcms/payload/templates/website#main`,
},
{
name: 'plugin',

View File

@@ -1,4 +1,3 @@
import execa from 'execa'
import fse from 'fs-extra'
import { fileURLToPath } from 'node:url'
import path from 'path'
@@ -9,6 +8,7 @@ const dirname = path.dirname(filename)
import type { NextAppDetails } from '../types.js'
import { copyRecursiveSync } from '../utils/copy-recursive-sync.js'
import { getLatestPackageVersion } from '../utils/getLatestPackageVersion.js'
import { info } from '../utils/log.js'
import { getPackageManager } from './get-package-manager.js'
import { installPackages } from './install-packages.js'
@@ -36,15 +36,8 @@ export async function updatePayloadInProject(
const packageManager = await getPackageManager({ projectDir })
// Fetch latest Payload version from npm
const { exitCode: getLatestVersionExitCode, stdout: latestPayloadVersion } = await execa('npm', [
'show',
'payload',
'version',
])
if (getLatestVersionExitCode !== 0) {
throw new Error('Failed to fetch latest Payload version')
}
// Fetch latest Payload version
const latestPayloadVersion = await getLatestPackageVersion({ packageName: 'payload' })
if (payloadVersion === latestPayloadVersion) {
return { message: `Payload v${payloadVersion} is already up to date.`, success: true }

View File

@@ -8,7 +8,6 @@ import path from 'path'
import type { CliArgs } from './types.js'
import { configurePayloadConfig } from './lib/configure-payload-config.js'
import { PACKAGE_VERSION } from './lib/constants.js'
import { createProject } from './lib/create-project.js'
import { parseExample } from './lib/examples.js'
import { generateSecret } from './lib/generate-secret.js'
@@ -20,6 +19,7 @@ import { parseTemplate } from './lib/parse-template.js'
import { selectDb } from './lib/select-db.js'
import { getValidTemplates, validateTemplate } from './lib/templates.js'
import { updatePayloadInProject } from './lib/update-payload-in-project.js'
import { getLatestPackageVersion } from './utils/getLatestPackageVersion.js'
import { debug, error, info } from './utils/log.js'
import {
feedbackOutro,
@@ -78,13 +78,18 @@ export class Main {
async init(): Promise<void> {
try {
const debugFlag = this.args['--debug']
const LATEST_VERSION = await getLatestPackageVersion({
debug: debugFlag,
packageName: 'payload',
})
if (this.args['--help']) {
helpMessage()
process.exit(0)
}
const debugFlag = this.args['--debug']
// eslint-disable-next-line no-console
console.log('\n')
p.intro(chalk.bgCyan(chalk.black(' create-payload-app ')))
@@ -200,7 +205,7 @@ export class Main {
const templateArg = this.args['--template']
if (templateArg) {
const valid = validateTemplate(templateArg)
const valid = validateTemplate({ templateName: templateArg })
if (!valid) {
helpMessage()
process.exit(1)
@@ -230,7 +235,7 @@ export class Main {
}
if (debugFlag) {
debug(`Using ${exampleArg ? 'examples' : 'templates'} from git tag: v${PACKAGE_VERSION}`)
debug(`Using ${exampleArg ? 'examples' : 'templates'} from git tag: v${LATEST_VERSION}`)
}
if (!exampleArg) {

View File

@@ -0,0 +1,34 @@
/**
* Fetches the latest version of a package from the NPM registry.
*
* Used in determining the latest version of Payload to use in the generated templates.
*/
export async function getLatestPackageVersion({
debug = false,
packageName = 'payload',
}: {
debug?: boolean
/**
* Package name to fetch the latest version for based on the NPM registry URL
*
* Eg. for `'payload'`, it will fetch the version from `https://registry.npmjs.org/payload`
*
* @default 'payload'
*/
packageName?: string
}) {
try {
const response = await fetch(`https://registry.npmjs.org/${packageName}`)
const data = await response.json()
const latestVersion = data['dist-tags'].latest
if (debug) {
console.log(`Found latest version of ${packageName}: ${latestVersion}`)
}
return latestVersion
} catch (error) {
console.error('Error fetching Payload version:', error)
throw error
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-mongodb",
"version": "3.38.0",
"version": "3.39.1",
"description": "The officially supported MongoDB database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/db-postgres",
"version": "3.38.0",
"version": "3.39.1",
"description": "The officially supported Postgres database adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/live-preview",
"version": "3.38.0",
"version": "3.39.1",
"description": "The official live preview JavaScript SDK for Payload",
"homepage": "https://payloadcms.com",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/next",
"version": "3.38.0",
"version": "3.39.1",
"homepage": "https://payloadcms.com",
"repository": {
"type": "git",

View File

@@ -1,7 +1,7 @@
'use client'
import type { SanitizedConfig } from 'payload'
import { Link } from '@payloadcms/ui'
import { Button } from '@payloadcms/ui'
import { useParams, usePathname, useSearchParams } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
import React from 'react'
@@ -13,7 +13,6 @@ export const DocumentTabLink: React.FC<{
children?: React.ReactNode
href: string
isActive?: boolean
isCollection?: boolean
newTab?: boolean
}> = ({
adminRoute,
@@ -54,19 +53,17 @@ export const DocumentTabLink: React.FC<{
isActiveFromProps
return (
<li
<Button
aria-label={ariaLabel}
buttonStyle="tab"
className={[baseClass, isActive && `${baseClass}--active`].filter(Boolean).join(' ')}
disabled={isActive}
el={!isActive || href !== pathname ? 'link' : 'div'}
newTab={newTab}
size="medium"
to={!isActive || href !== pathname ? hrefWithLocale : undefined}
>
<Link
className={`${baseClass}__link`}
href={!isActive || href !== pathname ? hrefWithLocale : ''}
prefetch={false}
{...(newTab && { rel: 'noopener noreferrer', target: '_blank' })}
tabIndex={isActive ? -1 : 0}
>
{children}
</Link>
</li>
{children}
</Button>
)
}

View File

@@ -1,74 +1,24 @@
@import '../../../../scss/styles.scss';
@layer payload-default {
.doc-tab {
@extend %h5;
position: relative;
&__link {
text-decoration: none;
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
// Use a pseudo element for the accessability so that it doesn't take up DOM space
// Also because the parent element has `overflow: hidden` which would clip an outline
&:focus-visible::after {
content: '';
border: var(--accessibility-outline);
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
}
}
&:focus:not(:focus-visible) {
opacity: 1;
}
&::before {
content: '';
display: block;
position: absolute;
width: 100%;
height: 100%;
border-radius: var(--style-radius-s);
background-color: var(--theme-elevation-50);
opacity: 0;
}
display: flex;
justify-content: center;
align-items: center;
white-space: nowrap;
&:hover {
&::before {
opacity: 1;
}
.doc-tab__count {
.pill-version-count {
background-color: var(--theme-elevation-150);
}
}
&--active {
font-weight: 600;
&::before {
opacity: 1;
background-color: var(--theme-elevation-100);
}
.doc-tab {
&__count {
background-color: var(--theme-elevation-250);
}
.pill-version-count {
background-color: var(--theme-elevation-250);
}
&:hover {
.doc-tab {
&__count {
background-color: var(--theme-elevation-250);
}
.pill-version-count {
background-color: var(--theme-elevation-250);
}
}
}
@@ -80,16 +30,7 @@
gap: 4px;
width: 100%;
height: 100%;
line-height: base(1.2);
padding: base(0.2) base(0.6);
}
&__count {
line-height: base(0.8);
min-width: base(0.8);
text-align: center;
background-color: var(--theme-elevation-100);
border-radius: var(--style-radius-s);
line-height: calc(var(--base) * 1.2);
}
}
}

View File

@@ -68,7 +68,6 @@ export const DocumentTab: React.FC<
baseClass={baseClass}
href={href}
isActive={isActive}
isCollection={!!collectionConfig && !globalConfig}
newTab={newTab}
>
<span className={`${baseClass}__label`}>

View File

@@ -0,0 +1,9 @@
@layer payload-default {
.pill-version-count {
line-height: calc(var(--base) * 0.8);
min-width: calc(var(--base) * 0.8);
text-align: center;
background-color: var(--theme-elevation-100);
border-radius: var(--style-radius-s);
}
}

View File

@@ -2,7 +2,9 @@
import { useDocumentInfo } from '@payloadcms/ui'
import React from 'react'
import { baseClass } from '../../Tab/index.js'
import './index.scss'
const baseClass = 'pill-version-count'
export const VersionsPill: React.FC = () => {
const { versionCount } = useDocumentInfo()
@@ -11,5 +13,5 @@ export const VersionsPill: React.FC = () => {
return null
}
return <span className={`${baseClass}__count`}>{versionCount}</span>
return <span className={baseClass}>{versionCount}</span>
}

View File

@@ -4,7 +4,7 @@ import type { groupNavItems } from '@payloadcms/ui/shared'
import type { NavPreferences } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import { Link, NavGroup, useConfig, useTranslation } from '@payloadcms/ui'
import { BrowseByFolderButton, Link, NavGroup, useConfig, useTranslation } from '@payloadcms/ui'
import { EntityType } from '@payloadcms/ui/shared'
import { usePathname } from 'next/navigation.js'
import { formatAdminURL } from 'payload/shared'
@@ -20,14 +20,35 @@ export const DefaultNavClient: React.FC<{
const {
config: {
admin: {
routes: { browseByFolder: foldersRoute },
},
collections,
routes: { admin: adminRoute },
},
} = useConfig()
const [folderCollectionSlugs] = React.useState<string[]>(() => {
return collections.reduce<string[]>((acc, collection) => {
if (collection.admin.folders) {
acc.push(collection.slug)
}
return acc
}, [])
})
const { i18n } = useTranslation()
const folderURL = formatAdminURL({
adminRoute,
path: foldersRoute,
})
const viewingRootFolderView = pathname.startsWith(folderURL)
return (
<Fragment>
{folderCollectionSlugs.length > 0 && <BrowseByFolderButton active={viewingRootFolderView} />}
{groups.map(({ entities, label }, key) => {
return (
<NavGroup isOpen={navPreferences?.groups?.[label]?.open} key={key} label={label}>

View File

@@ -0,0 +1,161 @@
import type {
AdminViewServerProps,
BuildCollectionFolderViewResult,
FolderListViewServerPropsOnly,
ListQuery,
} from 'payload'
import { DefaultBrowseByFolderView, FolderProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { formatAdminURL } from '@payloadcms/ui/shared'
import { redirect } from 'next/navigation.js'
import { getFolderData } from 'payload'
import React from 'react'
import { getPreferences } from '../../utilities/getPreferences.js'
export type BuildFolderViewArgs = {
customCellProps?: Record<string, any>
disableBulkDelete?: boolean
disableBulkEdit?: boolean
enableRowSelections: boolean
folderID?: number | string
isInDrawer?: boolean
overrideEntityVisibility?: boolean
query: ListQuery
} & AdminViewServerProps
export const buildBrowseByFolderView = async (
args: BuildFolderViewArgs,
): Promise<BuildCollectionFolderViewResult> => {
const {
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
folderCollectionSlugs,
folderID,
initPageResult,
isInDrawer,
params,
query: queryFromArgs,
searchParams,
} = args
const {
locale: fullLocale,
permissions,
req: {
i18n,
payload,
payload: { config },
query: queryFromReq,
user,
},
visibleEntities,
} = initPageResult
const collections = folderCollectionSlugs.filter(
(collectionSlug) =>
permissions?.collections?.[collectionSlug]?.read &&
visibleEntities.collections.includes(collectionSlug),
)
if (!collections.length) {
throw new Error('not-found')
}
const query = queryFromArgs || queryFromReq
const selectedCollectionSlugs: string[] =
Array.isArray(query?.relationTo) && query.relationTo.length
? query.relationTo
: [...folderCollectionSlugs, config.folders.slug]
const {
routes: { admin: adminRoute },
} = config
const { breadcrumbs, documents, subfolders } = await getFolderData({
folderID,
payload: initPageResult.req.payload,
search: query?.search as string,
user: initPageResult.req.user,
})
const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id
if (
!isInDrawer &&
((resolvedFolderID && folderID && folderID !== resolvedFolderID) ||
(folderID && !resolvedFolderID))
) {
return redirect(
formatAdminURL({
adminRoute,
path: config.admin.routes.browseByFolder,
serverURL: config.serverURL,
}),
)
}
const browseByFolderPreferences = await getPreferences<{ viewPreference: string }>(
'browse-by-folder',
payload,
user.id,
user.collection,
)
const serverProps: Omit<FolderListViewServerPropsOnly, 'collectionConfig' | 'listPreferences'> = {
documents,
i18n,
locale: fullLocale,
params,
payload,
permissions,
searchParams,
subfolders,
user,
}
// const folderViewSlots = renderFolderViewSlots({
// clientProps: {
// },
// description: staticDescription,
// payload,
// serverProps,
// })
// documents cannot be created without a parent folder in this view
const hasCreatePermissionCollectionSlugs = folderID
? [config.folders.slug, ...folderCollectionSlugs]
: [config.folders.slug]
return {
View: (
<FolderProvider
breadcrumbs={breadcrumbs}
documents={documents}
filteredCollectionSlugs={selectedCollectionSlugs}
folderCollectionSlugs={folderCollectionSlugs}
folderID={folderID}
subfolders={subfolders}
>
<HydrateAuthProvider permissions={permissions} />
{RenderServerComponent({
clientProps: {
// ...folderViewSlots,
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
hasCreatePermissionCollectionSlugs,
selectedCollectionSlugs,
viewPreference: browseByFolderPreferences?.value?.viewPreference,
},
// Component:config.folders?.components?.views?.list?.Component,
Fallback: DefaultBrowseByFolderView,
importMap: payload.importMap,
serverProps,
})}
</FolderProvider>
),
}
}

View File

@@ -0,0 +1,20 @@
import type React from 'react'
import { notFound } from 'next/navigation.js'
import type { BuildFolderViewArgs } from './buildView.js'
import { buildBrowseByFolderView } from './buildView.js'
export const BrowseByFolder: React.FC<BuildFolderViewArgs> = async (args) => {
try {
const { View } = await buildBrowseByFolderView(args)
return View
} catch (error) {
if (error.message === 'not-found') {
notFound()
} else {
console.error(error) // eslint-disable-line no-console
}
}
}

View File

@@ -0,0 +1,23 @@
import type { Metadata } from 'next'
import type { GenerateViewMetadata } from '../Root/index.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateBrowseByFolderMetadata = async (
args: Parameters<GenerateViewMetadata>[0],
): Promise<Metadata> => {
const { config, i18n } = args
const title: string = i18n.t('folder:browseByFolder')
const description: string = ''
const keywords: string = ''
return generateMetadata({
...(config.admin.meta || {}),
description,
keywords,
serverURL: config.serverURL,
title,
})
}

View File

@@ -0,0 +1,207 @@
import type {
AdminViewServerProps,
BuildCollectionFolderViewResult,
FolderListViewServerPropsOnly,
ListQuery,
Where,
} from 'payload'
import { DefaultCollectionFolderView, FolderProvider, HydrateAuthProvider } from '@payloadcms/ui'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { formatAdminURL, mergeListSearchAndWhere } from '@payloadcms/ui/shared'
import { redirect } from 'next/navigation.js'
import { getFolderData, parseDocumentID } from 'payload'
import React from 'react'
import { getPreferences } from '../../utilities/getPreferences.js'
// import { renderFolderViewSlots } from './renderFolderViewSlots.js'
export type BuildCollectionFolderViewStateArgs = {
disableBulkDelete?: boolean
disableBulkEdit?: boolean
enableRowSelections: boolean
folderID?: number | string
isInDrawer?: boolean
overrideEntityVisibility?: boolean
query: ListQuery
} & AdminViewServerProps
/**
* Builds the entire view for collection-folder views on the server
*/
export const buildCollectionFolderView = async (
args: BuildCollectionFolderViewStateArgs,
): Promise<BuildCollectionFolderViewResult> => {
const {
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
folderCollectionSlugs,
folderID,
initPageResult,
isInDrawer,
overrideEntityVisibility,
params,
query: queryFromArgs,
searchParams,
} = args
const {
collectionConfig,
collectionConfig: { slug: collectionSlug },
locale: fullLocale,
permissions,
req: {
i18n,
payload,
payload: { config },
query: queryFromReq,
user,
},
visibleEntities,
} = initPageResult
if (!permissions?.collections?.[collectionSlug]?.read) {
throw new Error('not-found')
}
if (collectionConfig) {
const query = queryFromArgs || queryFromReq
const collectionFolderPreferences = await getPreferences<{ viewPreference: string }>(
`${collectionSlug}-collection-folder`,
payload,
user.id,
user.collection,
)
const {
routes: { admin: adminRoute },
} = config
if (
(!visibleEntities.collections.includes(collectionSlug) && !overrideEntityVisibility) ||
!folderCollectionSlugs.includes(collectionSlug)
) {
throw new Error('not-found')
}
const whereConstraints = [
mergeListSearchAndWhere({
collectionConfig,
search: typeof query?.search === 'string' ? query.search : undefined,
where: (query?.where as Where) || undefined,
}),
]
if (folderID) {
whereConstraints.push({
[config.folders.fieldName]: {
equals: parseDocumentID({ id: folderID, collectionSlug, payload }),
},
})
} else {
whereConstraints.push({
[config.folders.fieldName]: {
exists: false,
},
})
}
const { breadcrumbs, documents, subfolders } = await getFolderData({
collectionSlug,
folderID,
payload: initPageResult.req.payload,
search: query?.search as string,
user: initPageResult.req.user,
})
const resolvedFolderID = breadcrumbs[breadcrumbs.length - 1]?.id
if (
!isInDrawer &&
((resolvedFolderID && folderID && folderID !== resolvedFolderID) ||
(folderID && !resolvedFolderID))
) {
return redirect(
formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/${config.folders.slug}`,
serverURL: config.serverURL,
}),
)
}
const newDocumentURL = formatAdminURL({
adminRoute,
path: `/collections/${collectionSlug}/create`,
})
const hasCreatePermission = permissions?.collections?.[collectionSlug]?.create
const serverProps: FolderListViewServerPropsOnly = {
collectionConfig,
documents,
i18n,
locale: fullLocale,
params,
payload,
permissions,
searchParams,
subfolders,
user,
}
// We could support slots in the folder view in the future
// const folderViewSlots = renderFolderViewSlots({
// clientProps: {
// collectionSlug,
// hasCreatePermission,
// newDocumentURL,
// },
// collectionConfig,
// description: typeof collectionConfig.admin.description === 'function'
// ? collectionConfig.admin.description({ t: i18n.t })
// : collectionConfig.admin.description,
// payload,
// serverProps,
// })
const search = query?.search as string
return {
View: (
<FolderProvider
breadcrumbs={breadcrumbs}
collectionSlug={collectionSlug}
documents={documents}
folderCollectionSlugs={folderCollectionSlugs}
folderID={folderID}
search={search}
subfolders={subfolders}
>
<HydrateAuthProvider permissions={permissions} />
{RenderServerComponent({
clientProps: {
// ...folderViewSlots,
collectionSlug,
disableBulkDelete,
disableBulkEdit,
enableRowSelections,
hasCreatePermission,
newDocumentURL,
viewPreference: collectionFolderPreferences?.value?.viewPreference,
},
Component: collectionConfig?.admin?.components?.views?.list?.Component,
Fallback: DefaultCollectionFolderView,
importMap: payload.importMap,
serverProps,
})}
</FolderProvider>
),
}
}
throw new Error('not-found')
}

View File

@@ -0,0 +1,20 @@
import type React from 'react'
import { notFound } from 'next/navigation.js'
import type { BuildCollectionFolderViewStateArgs } from './buildView.js'
import { buildCollectionFolderView } from './buildView.js'
export const CollectionFolderView: React.FC<BuildCollectionFolderViewStateArgs> = async (args) => {
try {
const { View } = await buildCollectionFolderView(args)
return View
} catch (error) {
if (error.message === 'not-found') {
notFound()
} else {
console.error(error) // eslint-disable-line no-console
}
}
}

View File

@@ -0,0 +1,35 @@
import type { Metadata } from 'next'
import type { SanitizedCollectionConfig } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import type { GenerateViewMetadata } from '../Root/index.js'
import { generateMetadata } from '../../utilities/meta.js'
export const generateCollectionFolderMetadata = async (
args: {
collectionConfig: SanitizedCollectionConfig
} & Parameters<GenerateViewMetadata>[0],
): Promise<Metadata> => {
const { collectionConfig, config, i18n } = args
let title: string = ''
const description: string = ''
const keywords: string = ''
if (collectionConfig) {
title = getTranslation(collectionConfig.labels.singular, i18n)
}
title = `${title ? `${title} ` : title}${i18n.t('folder:folders')}`
return generateMetadata({
...(config.admin.meta || {}),
description,
keywords,
serverURL: config.serverURL,
title,
...(collectionConfig?.admin?.meta || {}),
})
}

View File

@@ -0,0 +1,99 @@
import type {
AfterFolderListClientProps,
AfterFolderListTableClientProps,
AfterFolderListTableServerPropsOnly,
BeforeFolderListClientProps,
BeforeFolderListServerPropsOnly,
BeforeFolderListTableClientProps,
BeforeFolderListTableServerPropsOnly,
FolderListViewServerPropsOnly,
FolderListViewSlots,
ListViewSlotSharedClientProps,
Payload,
SanitizedCollectionConfig,
StaticDescription,
ViewDescriptionClientProps,
ViewDescriptionServerPropsOnly,
} from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
type Args = {
clientProps: ListViewSlotSharedClientProps
collectionConfig: SanitizedCollectionConfig
description?: StaticDescription
payload: Payload
serverProps: FolderListViewServerPropsOnly
}
export const renderFolderViewSlots = ({
clientProps,
collectionConfig,
description,
payload,
serverProps,
}: Args): FolderListViewSlots => {
const result: FolderListViewSlots = {} as FolderListViewSlots
if (collectionConfig.admin.components?.afterList) {
result.AfterFolderList = RenderServerComponent({
clientProps: clientProps satisfies AfterFolderListClientProps,
Component: collectionConfig.admin.components.afterList,
importMap: payload.importMap,
serverProps: serverProps satisfies AfterFolderListTableServerPropsOnly,
})
}
const listMenuItems = collectionConfig.admin.components?.listMenuItems
if (Array.isArray(listMenuItems)) {
result.listMenuItems = [
RenderServerComponent({
clientProps,
Component: listMenuItems,
importMap: payload.importMap,
serverProps,
}),
]
}
if (collectionConfig.admin.components?.afterListTable) {
result.AfterFolderListTable = RenderServerComponent({
clientProps: clientProps satisfies AfterFolderListTableClientProps,
Component: collectionConfig.admin.components.afterListTable,
importMap: payload.importMap,
serverProps: serverProps satisfies AfterFolderListTableServerPropsOnly,
})
}
if (collectionConfig.admin.components?.beforeList) {
result.BeforeFolderList = RenderServerComponent({
clientProps: clientProps satisfies BeforeFolderListClientProps,
Component: collectionConfig.admin.components.beforeList,
importMap: payload.importMap,
serverProps: serverProps satisfies BeforeFolderListServerPropsOnly,
})
}
if (collectionConfig.admin.components?.beforeListTable) {
result.BeforeFolderListTable = RenderServerComponent({
clientProps: clientProps satisfies BeforeFolderListTableClientProps,
Component: collectionConfig.admin.components.beforeListTable,
importMap: payload.importMap,
serverProps: serverProps satisfies BeforeFolderListTableServerPropsOnly,
})
}
if (collectionConfig.admin.components?.Description) {
result.Description = RenderServerComponent({
clientProps: {
collectionSlug: collectionConfig.slug,
description,
} satisfies ViewDescriptionClientProps,
Component: collectionConfig.admin.components.Description,
importMap: payload.importMap,
serverProps: serverProps satisfies ViewDescriptionServerPropsOnly,
})
}
return result
}

View File

@@ -32,9 +32,12 @@ import { renderDocumentSlots } from './renderDocumentSlots.js'
export const generateMetadata: GenerateEditViewMetadata = async (args) => getMetaBySegment(args)
// This function will be responsible for rendering an Edit Document view
// it will be called on the server for Edit page views as well as
// called on-demand from document drawers
/**
* This function is responsible for rendering
* an Edit Document view on the server for both:
* - default document edit views
* - on-demand edit views within drawers
*/
export const renderDocument = async ({
disableActions,
documentSubViewType,

View File

@@ -40,6 +40,12 @@ type RenderListViewArgs = {
redirectAfterDuplicate?: boolean
} & AdminViewServerProps
/**
* This function is responsible for rendering
* the list view on the server for both:
* - default list view
* - list view within drawers
*/
export const renderListView = async (
args: RenderListViewArgs,
): Promise<{

View File

@@ -1,6 +1,6 @@
import type { AdminViewConfig, SanitizedConfig } from 'payload'
import type { ViewFromConfig } from './getViewFromConfig.js'
import type { ViewFromConfig } from './getRouteData.js'
import { isPathMatchingRoute } from './isPathMatchingRoute.js'

View File

@@ -1,5 +1,6 @@
import type {
AdminViewServerProps,
CollectionSlug,
DocumentSubViewTypes,
ImportMap,
PayloadComponent,
@@ -14,6 +15,8 @@ import { formatAdminURL } from 'payload/shared'
import type { initPage } from '../../utilities/initPage/index.js'
import { Account } from '../Account/index.js'
import { BrowseByFolder } from '../BrowseByFolder/index.js'
import { CollectionFolderView } from '../CollectionFolders/index.js'
import { CreateFirstUserView } from '../CreateFirstUser/index.js'
import { Dashboard } from '../Dashboard/index.js'
import { Document as DocumentView } from '../Document/index.js'
@@ -31,6 +34,7 @@ import { isPathMatchingRoute } from './isPathMatchingRoute.js'
const baseClasses = {
account: 'account',
folders: 'folders',
forgot: forgotPasswordBaseClass,
login: loginBaseClass,
reset: resetPasswordBaseClass,
@@ -48,6 +52,7 @@ export type ViewFromConfig = {
const oneSegmentViews: OneSegmentViews = {
account: Account,
browseByFolder: BrowseByFolder,
createFirstUser: CreateFirstUserView,
forgot: ForgotPasswordView,
inactivity: LogoutInactivity,
@@ -56,7 +61,7 @@ const oneSegmentViews: OneSegmentViews = {
unauthorized: UnauthorizedView,
}
type GetViewFromConfigArgs = {
type GetRouteDataArgs = {
adminRoute: string
config: SanitizedConfig
currentRoute: string
@@ -67,9 +72,11 @@ type GetViewFromConfigArgs = {
segments: string[]
}
type GetViewFromConfigResult = {
type GetRouteDataResult = {
DefaultView: ViewFromConfig
documentSubViewType?: DocumentSubViewTypes
folderCollectionSlugs: CollectionSlug[]
folderID?: string
initPageOptions: Parameters<typeof initPage>[0]
serverProps: ServerPropsFromView
templateClassName: string
@@ -77,19 +84,20 @@ type GetViewFromConfigResult = {
viewType?: ViewTypes
}
export const getViewFromConfig = ({
export const getRouteData = ({
adminRoute,
config,
currentRoute,
importMap,
searchParams,
segments,
}: GetViewFromConfigArgs): GetViewFromConfigResult => {
}: GetRouteDataArgs): GetRouteDataResult => {
let ViewToRender: ViewFromConfig = null
let templateClassName: string
let templateType: 'default' | 'minimal' | undefined
let documentSubViewType: DocumentSubViewTypes
let viewType: ViewTypes
let folderID: string
const initPageOptions: Parameters<typeof initPage>[0] = {
config,
@@ -105,6 +113,13 @@ export const getViewFromConfig = ({
let matchedCollection: SanitizedConfig['collections'][number] = undefined
let matchedGlobal: SanitizedConfig['globals'][number] = undefined
const folderCollectionSlugs = config.collections.reduce((acc, { slug, admin }) => {
if (admin?.folders) {
return [...acc, slug]
}
return acc
}, [])
const serverProps: ServerPropsFromView = {
viewActions: config?.admin?.components?.actions || [],
}
@@ -153,6 +168,7 @@ export const getViewFromConfig = ({
if (oneSegmentViews[viewKey]) {
// --> /account
// --> /create-first-user
// --> /browse-by-folder
// --> /forgot
// --> /login
// --> /logout
@@ -170,6 +186,11 @@ export const getViewFromConfig = ({
templateType = 'default'
viewType = 'account'
}
if (folderCollectionSlugs.length && viewKey === 'browseByFolder') {
templateType = 'default'
viewType = 'folders'
}
}
break
}
@@ -182,9 +203,19 @@ export const getViewFromConfig = ({
templateClassName = baseClasses[segmentTwo]
templateType = 'minimal'
viewType = 'reset'
}
if (isCollection && matchedCollection) {
} else if (
folderCollectionSlugs.length &&
`/${segmentOne}` === config.admin.routes.browseByFolder
) {
// --> /browse-by-folder/:folderID
ViewToRender = {
Component: oneSegmentViews.browseByFolder,
}
templateClassName = baseClasses.folders
templateType = 'default'
viewType = 'folders'
folderID = segmentTwo
} else if (isCollection && matchedCollection) {
// --> /collections/:collectionSlug
ViewToRender = {
@@ -229,31 +260,47 @@ export const getViewFromConfig = ({
templateType = 'minimal'
viewType = 'verify'
} else if (isCollection && matchedCollection) {
// Custom Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:versionID
if (
segmentThree === config.folders.slug &&
folderCollectionSlugs.includes(matchedCollection.slug)
) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug
// --> /collections/:collectionSlug/:folderCollectionSlug/:folderID
ViewToRender = {
Component: CollectionFolderView,
}
ViewToRender = {
Component: DocumentView,
templateClassName = `collection-folders`
templateType = 'default'
viewType = 'collection-folders'
folderID = segmentFour
} else {
// Collection Edit Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/api
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:versionID
ViewToRender = {
Component: DocumentView,
}
templateClassName = `collection-default-edit`
templateType = 'default'
const viewInfo = getDocumentViewInfo([segmentFour, segmentFive])
viewType = viewInfo.viewType
documentSubViewType = viewInfo.documentSubViewType
attachViewActions({
collectionOrGlobal: matchedCollection,
serverProps,
viewKeyArg: documentSubViewType,
})
}
templateClassName = `collection-default-edit`
templateType = 'default'
const viewInfo = getDocumentViewInfo([segmentFour, segmentFive])
viewType = viewInfo.viewType
documentSubViewType = viewInfo.documentSubViewType
attachViewActions({
collectionOrGlobal: matchedCollection,
serverProps,
viewKeyArg: documentSubViewType,
})
} else if (isGlobal && matchedGlobal) {
// Custom Views
// Global Edit Views
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/preview
// --> /globals/:globalSlug/versions/:versionID
@@ -288,6 +335,8 @@ export const getViewFromConfig = ({
return {
DefaultView: ViewToRender,
documentSubViewType,
folderCollectionSlugs,
folderID,
initPageOptions,
serverProps,
templateClassName,

View File

@@ -1,22 +1,23 @@
import type { I18nClient } from '@payloadcms/translations'
import type { Metadata } from 'next'
import type {
AdminViewClientProps,
AdminViewServerPropsOnly,
ImportMap,
SanitizedConfig,
} from 'payload'
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig'
import { notFound, redirect } from 'next/navigation.js'
import {
type AdminViewClientProps,
type AdminViewServerPropsOnly,
type ImportMap,
parseDocumentID,
type SanitizedConfig,
} from 'payload'
import { formatAdminURL } from 'payload/shared'
import React, { Fragment } from 'react'
import React from 'react'
import { DefaultTemplate } from '../../templates/Default/index.js'
import { MinimalTemplate } from '../../templates/Minimal/index.js'
import { initPage } from '../../utilities/initPage/index.js'
import { getViewFromConfig } from './getViewFromConfig.js'
import { getRouteData } from './getRouteData.js'
export type GenerateViewMetadata = (args: {
config: SanitizedConfig
@@ -64,12 +65,14 @@ export const RootPage = async ({
const {
DefaultView,
documentSubViewType,
folderCollectionSlugs,
folderID: folderIDParam,
initPageOptions,
serverProps,
templateClassName,
templateType,
viewType,
} = getViewFromConfig({
} = getRouteData({
adminRoute,
config,
currentRoute,
@@ -89,6 +92,10 @@ export const RootPage = async ({
})
?.then((doc) => !!doc))
/**
* This function is responsible for handling the case where the view is not found.
* The current route did not match any default views or custom route views.
*/
if (!DefaultView?.Component && !DefaultView?.payloadComponent) {
if (initPageResult?.req?.user) {
notFound()
@@ -132,8 +139,20 @@ export const RootPage = async ({
importMap,
})
const payload = initPageResult?.req.payload
const folderID = parseDocumentID({
id: folderIDParam,
collectionSlug: payload.config.folders.slug,
payload,
})
const RenderedView = RenderServerComponent({
clientProps: { clientConfig, documentSubViewType, viewType } satisfies AdminViewClientProps,
clientProps: {
clientConfig,
documentSubViewType,
folderCollectionSlugs,
viewType,
} satisfies AdminViewClientProps,
Component: DefaultView.payloadComponent,
Fallback: DefaultView.Component,
importMap,
@@ -141,6 +160,7 @@ export const RootPage = async ({
...serverProps,
clientConfig,
docID: initPageResult?.docID,
folderID,
i18n: initPageResult?.req.i18n,
importMap,
initPageResult,
@@ -151,8 +171,8 @@ export const RootPage = async ({
})
return (
<Fragment>
{!templateType && <Fragment>{RenderedView}</Fragment>}
<React.Fragment>
{!templateType && <React.Fragment>{RenderedView}</React.Fragment>}
{templateType === 'minimal' && (
<MinimalTemplate className={templateClassName}>{RenderedView}</MinimalTemplate>
)}
@@ -182,6 +202,6 @@ export const RootPage = async ({
{RenderedView}
</DefaultTemplate>
)}
</Fragment>
</React.Fragment>
)
}

View File

@@ -3,6 +3,8 @@ import type { SanitizedConfig } from 'payload'
import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js'
import { generateAccountViewMetadata } from '../Account/metadata.js'
import { generateBrowseByFolderMetadata } from '../BrowseByFolder/metadata.js'
import { generateCollectionFolderMetadata } from '../CollectionFolders/metadata.js'
import { generateCreateFirstUserViewMetadata } from '../CreateFirstUser/metadata.js'
import { generateDashboardViewMetadata } from '../Dashboard/metadata.js'
import { generateDocumentViewMetadata } from '../Document/metadata.js'
@@ -18,6 +20,7 @@ import { getCustomViewByRoute } from './getCustomViewByRoute.js'
const oneSegmentMeta = {
'create-first-user': generateCreateFirstUserViewMetadata,
folders: generateBrowseByFolderMetadata,
forgot: generateForgotPasswordViewMetadata,
login: generateLoginViewMetadata,
logout: generateUnauthorizedViewMetadata,
@@ -40,12 +43,18 @@ export const generatePageMetadata = async ({
params: paramsPromise,
}: Args) => {
const config = await configPromise
const params = await paramsPromise
const folderCollectionSlugs = config.collections.reduce((acc, { slug, admin }) => {
if (admin?.folders) {
return [...acc, slug]
}
return acc
}, [])
const segments = Array.isArray(params.segments) ? params.segments : []
const currentRoute = `/${segments.join('/')}`
const [segmentOne, segmentTwo] = segments
const [segmentOne, segmentTwo, segmentThree] = segments
const isGlobal = segmentOne === 'globals'
const isCollection = segmentOne === 'collections'
@@ -72,7 +81,14 @@ export const generatePageMetadata = async ({
break
}
case 1: {
if (oneSegmentMeta[segmentOne] && segmentOne !== 'account') {
if (folderCollectionSlugs.length && `/${segmentOne}` === config.admin.routes.browseByFolder) {
// --> /:folderCollectionSlug
meta = await oneSegmentMeta.folders({ config, i18n })
} else if (segmentOne === 'account') {
// --> /account
meta = await generateAccountViewMetadata({ config, i18n })
break
} else if (oneSegmentMeta[segmentOne]) {
// --> /create-first-user
// --> /forgot
// --> /login
@@ -81,10 +97,6 @@ export const generatePageMetadata = async ({
// --> /unauthorized
meta = await oneSegmentMeta[segmentOne]({ config, i18n })
break
} else if (segmentOne === 'account') {
// --> /account
meta = await generateAccountViewMetadata({ config, i18n })
break
}
break
}
@@ -92,8 +104,13 @@ export const generatePageMetadata = async ({
if (`/${segmentOne}` === config.admin.routes.reset) {
// --> /reset/:token
meta = await generateResetPasswordViewMetadata({ config, i18n })
}
if (isCollection) {
} else if (
folderCollectionSlugs.length &&
`/${segmentOne}` === config.admin.routes.browseByFolder
) {
// --> /browse-by-folder/:folderID
meta = await generateBrowseByFolderMetadata({ config, i18n })
} else if (isCollection) {
// --> /collections/:collectionSlug
meta = await generateListViewMetadata({ collectionConfig, config, i18n })
} else if (isGlobal) {
@@ -112,15 +129,29 @@ export const generatePageMetadata = async ({
// --> /:collectionSlug/verify/:token
meta = await generateVerifyViewMetadata({ config, i18n })
} else if (isCollection) {
// Custom Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:version
// --> /collections/:collectionSlug/:id/api
meta = await generateDocumentViewMetadata({ collectionConfig, config, i18n, params })
if (segmentThree === config.folders.slug) {
if (folderCollectionSlugs.includes(collectionConfig.slug)) {
// Collection Folder Views
// --> /collections/:collectionSlug/:folderCollectionSlug
// --> /collections/:collectionSlug/:folderCollectionSlug/:id
meta = await generateCollectionFolderMetadata({
collectionConfig,
config,
i18n,
params,
})
}
} else {
// Collection Document Views
// --> /collections/:collectionSlug/:id
// --> /collections/:collectionSlug/:id/preview
// --> /collections/:collectionSlug/:id/versions
// --> /collections/:collectionSlug/:id/versions/:version
// --> /collections/:collectionSlug/:id/api
meta = await generateDocumentViewMetadata({ collectionConfig, config, i18n, params })
}
} else if (isGlobal) {
// Custom Views
// Global Document Views
// --> /globals/:globalSlug/versions
// --> /globals/:globalSlug/versions/:version
// --> /globals/:globalSlug/preview

View File

@@ -18,9 +18,11 @@ export const renderPill = (data, latestVersion, currentLabel, previousLabel, pil
return (
<React.Fragment>
{data?.id === latestVersion ? (
<Pill pillStyle={pillStyle}>{currentLabel}</Pill>
<Pill pillStyle={pillStyle} size="small">
{currentLabel}
</Pill>
) : (
<Pill>{previousLabel}</Pill>
<Pill size="small">{previousLabel}</Pill>
)}
&nbsp;&nbsp;
</React.Fragment>

View File

@@ -1,7 +1,7 @@
/**
* @param {import('next').NextConfig} nextConfig
* @param {Object} [options] - Optional configuration options
* @param {boolean} [options.devBundleServerPackages] - Whether to bundle server packages in development mode. @default true
* @param {Object} [sortOnOptions] - Optional configuration options
* @param {boolean} [sortOnOptions.devBundleServerPackages] - Whether to bundle server packages in development mode. @default true
*
* @returns {import('next').NextConfig}
* */

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ export type ListQuery = {
search?: string
sort?: Sort
where?: Where
}
} & Record<string, unknown>
export type BuildTableStateArgs = {
collectionSlug: string | string[]
@@ -71,3 +71,7 @@ export type BuildTableStateArgs = {
req: PayloadRequest
tableAppearance?: 'condensed' | 'default'
}
export type BuildCollectionFolderViewResult = {
View: React.ReactNode
}

View File

@@ -563,6 +563,7 @@ export type DocumentSlots = {
}
export type {
BuildCollectionFolderViewResult,
BuildTableStateArgs,
DefaultServerFunctionArgs,
ListQuery,
@@ -618,6 +619,26 @@ export type {
EditViewProps,
} from './views/document.js'
export type {
AfterFolderListClientProps,
AfterFolderListServerProps,
AfterFolderListServerPropsOnly,
AfterFolderListTableClientProps,
AfterFolderListTableServerProps,
AfterFolderListTableServerPropsOnly,
BeforeFolderListClientProps,
BeforeFolderListServerProps,
BeforeFolderListServerPropsOnly,
BeforeFolderListTableClientProps,
BeforeFolderListTableServerProps,
BeforeFolderListTableServerPropsOnly,
FolderListViewClientProps,
FolderListViewServerProps,
FolderListViewServerPropsOnly,
FolderListViewSlots,
FolderListViewSlotSharedClientProps,
} from './views/folderList.js'
export type {
AdminViewClientProps,
/**

View File

@@ -0,0 +1,60 @@
import type { ServerProps } from '../../config/types.js'
import type { FolderOrDocument } from '../../folders/types.js'
import type { SanitizedCollectionConfig } from '../../index.js'
export type FolderListViewSlots = {
AfterFolderList?: React.ReactNode
AfterFolderListTable?: React.ReactNode
BeforeFolderList?: React.ReactNode
BeforeFolderListTable?: React.ReactNode
Description?: React.ReactNode
listMenuItems?: React.ReactNode[]
Table: React.ReactNode
}
export type FolderListViewServerPropsOnly = {
collectionConfig: SanitizedCollectionConfig
documents: FolderOrDocument[]
subfolders: FolderOrDocument[]
} & ServerProps
export type FolderListViewServerProps = FolderListViewClientProps & FolderListViewServerPropsOnly
export type FolderListViewClientProps = {
beforeActions?: React.ReactNode[]
collectionSlug: SanitizedCollectionConfig['slug']
disableBulkDelete?: boolean
disableBulkEdit?: boolean
enableRowSelections?: boolean
hasCreatePermission: boolean
newDocumentURL: string
viewPreference: 'grid' | 'list'
} & FolderListViewSlots
export type FolderListViewSlotSharedClientProps = {
collectionSlug: SanitizedCollectionConfig['slug']
hasCreatePermission: boolean
newDocumentURL: string
}
// BeforeFolderList
export type BeforeFolderListClientProps = FolderListViewSlotSharedClientProps
export type BeforeFolderListServerPropsOnly = {} & FolderListViewServerPropsOnly
export type BeforeFolderListServerProps = BeforeFolderListClientProps &
BeforeFolderListServerPropsOnly
// BeforeFolderListTable
export type BeforeFolderListTableClientProps = FolderListViewSlotSharedClientProps
export type BeforeFolderListTableServerPropsOnly = {} & FolderListViewServerPropsOnly
export type BeforeFolderListTableServerProps = BeforeFolderListTableClientProps &
BeforeFolderListTableServerPropsOnly
// AfterFolderList
export type AfterFolderListClientProps = FolderListViewSlotSharedClientProps
export type AfterFolderListServerPropsOnly = {} & FolderListViewServerPropsOnly
export type AfterFolderListServerProps = AfterFolderListClientProps & AfterFolderListServerPropsOnly
// AfterFolderListTable
export type AfterFolderListTableClientProps = FolderListViewSlotSharedClientProps
export type AfterFolderListTableServerPropsOnly = {} & FolderListViewServerPropsOnly
export type AfterFolderListTableServerProps = AfterFolderListTableClientProps &
AfterFolderListTableServerPropsOnly

View File

@@ -1,4 +1,4 @@
import type { ClientTranslationsObject, I18n } from '@payloadcms/translations'
import type { ClientTranslationsObject } from '@payloadcms/translations'
import type { SanitizedPermissions } from '../../auth/index.js'
import type { ImportMap } from '../../bin/generateImportMap/index.js'
@@ -8,13 +8,12 @@ import type {
CustomComponent,
Locale,
MetaConfig,
Params,
PayloadComponent,
SanitizedConfig,
ServerProps,
} from '../../config/types.js'
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
import type { Payload, PayloadRequest } from '../../types/index.js'
import type { PayloadRequest } from '../../types/index.js'
import type { LanguageOptions } from '../LanguageOptions.js'
import type { Data, StaticDescription } from '../types.js'
import type { DocumentSubViewTypes } from './document.js'
@@ -32,6 +31,7 @@ export type AdminViewConfig = {
export type AdminViewClientProps = {
clientConfig: ClientConfig
documentSubViewType?: DocumentSubViewTypes
folderCollectionSlugs?: SanitizedCollectionConfig['slug'][]
viewType: ViewTypes
}
@@ -42,9 +42,14 @@ export type AdminViewServerPropsOnly = {
* @todo remove `docID` here as it is already contained in `initPageResult`
*/
readonly docID?: number | string
readonly folderID?: number | string
readonly importMap: ImportMap
readonly initialData?: Data
readonly initPageResult: InitPageResult
readonly params?: { [key: string]: string | string[] | undefined }
readonly redirectAfterCreate?: boolean
readonly redirectAfterDelete?: boolean
readonly redirectAfterDuplicate?: boolean
} & ServerProps
export type AdminViewServerProps = AdminViewClientProps & AdminViewServerPropsOnly
@@ -78,8 +83,10 @@ export type InitPageResult = {
*/
export type ViewTypes =
| 'account'
| 'collection-folders'
| 'dashboard'
| 'document'
| 'folders'
| 'list'
| 'reset'
| 'verify'

View File

@@ -38,7 +38,7 @@ export type ImportMap = {
[path: UserImportPath]: any
}
export type AddToImportMap = (payloadComponent: PayloadComponent | PayloadComponent[]) => void
export type AddToImportMap = (payloadComponent?: PayloadComponent | PayloadComponent[]) => void
export async function generateImportMap(
config: SanitizedConfig,

View File

@@ -69,6 +69,7 @@ export const addDefaultsToCollectionConfig = (collection: CollectionConfig): Col
custom: {},
enableRichTextLink: true,
enableRichTextRelationship: true,
folders: false,
useAsTitle: 'id',
...(collection.admin || {}),
pagination: {

View File

@@ -32,6 +32,7 @@ import type {
RelationshipField,
UploadField,
} from '../../fields/config/types.js'
import type { CollectionFoldersConfiguration } from '../../folders/types.js'
import type {
CollectionSlug,
JsonObject,
@@ -256,6 +257,11 @@ export type AfterForgotPasswordHook = (args: {
context: RequestContext
}) => any
export type EnableFoldersOptions = {
// Displays the folder collection and parentFolder field in the document view
debug?: boolean
}
export type BaseListFilter = (args: {
limit: number
locale?: TypedLocale
@@ -339,6 +345,11 @@ export type CollectionAdminOptions = {
disableCopyToLocale?: boolean
enableRichTextLink?: boolean
enableRichTextRelationship?: boolean
/**
* Enables folders for this collection
* @deprecated this property will move out of `admin` in the next patch
*/
folders?: CollectionFoldersConfiguration
/**
* Specify a navigational group for collections in the admin sidebar.
* - Provide a string to place the entity in a custom group.
@@ -583,8 +594,9 @@ export type SanitizedJoins = {
export interface SanitizedCollectionConfig
extends Omit<
DeepRequired<CollectionConfig>,
'auth' | 'endpoints' | 'fields' | 'slug' | 'upload' | 'versions'
'admin' | 'auth' | 'endpoints' | 'fields' | 'slug' | 'upload' | 'versions'
> {
admin: CollectionAdminOptions
auth: Auth
endpoints: Endpoint[] | false
fields: Field[]

View File

@@ -2,6 +2,7 @@ import type { JobsConfig } from '../queues/config/types/index.js'
import type { Config } from './types.js'
import defaultAccess from '../auth/defaultAccess.js'
import { foldersSlug, parentFolderFieldName } from '../folders/constants.js'
/**
* @deprecated - remove in 4.0. This is error-prone, as mutating this object will affect any objects that use the defaults as a base.
@@ -23,6 +24,7 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
},
routes: {
account: '/account',
browseByFolder: '/browse-by-folder',
createFirstUser: '/create-first-user',
forgot: '/forgot',
inactivity: '/logout-inactivity',
@@ -98,6 +100,7 @@ export const addDefaultsToConfig = (config: Config): Config => {
},
routes: {
account: '/account',
browseByFolder: '/browse-by-folder',
createFirstUser: '/create-first-user',
forgot: '/forgot',
inactivity: '/logout-inactivity',
@@ -109,6 +112,13 @@ export const addDefaultsToConfig = (config: Config): Config => {
},
}
config.folders = {
slug: foldersSlug,
debug: false,
fieldName: parentFolderFieldName,
...(config.folders || {}),
}
config.bin = config.bin ?? []
config.collections = config.collections ?? []
config.cookiePrefix = config.cookiePrefix ?? 'payload'

View File

@@ -18,6 +18,7 @@ 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 { addFolderCollections } from '../folders/addFolderCollections.js'
import { sanitizeGlobal } from '../globals/config/sanitize.js'
import {
baseBlockFields,
@@ -190,6 +191,8 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
const collectionSlugs = new Set<CollectionSlug>()
await addFolderCollections(config as unknown as Config)
const validRelationships = [
...(config.collections.map((c) => c.slug) ?? []),
jobsCollectionSlug,
@@ -313,6 +316,7 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
for (const hook of Object.keys(hooks)) {
const defaultAmount = hook === 'afterRead' || hook === 'beforeChange' ? 1 : 0
if (hooks[hook]?.length > defaultAmount) {
// eslint-disable-next-line no-console
console.warn(
`The jobsCollectionOverrides function is returning a collection with an additional ${hook} hook defined. These hooks will not run unless the jobs.runHooks option is set to true. Setting this option to true will negatively impact performance.`,
)
@@ -374,7 +378,15 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
config.csrf.push(config.serverURL)
}
// Get deduped list of upload adapters
const uploadAdapters = new Set<string>()
// interact with all collections
for (const collection of config.collections) {
// deduped upload adapters
if (collection.upload?.adapter) {
uploadAdapters.add(collection.upload.adapter)
}
}
if (!config.upload) {
config.upload = { adapters: [] }
}

View File

@@ -40,6 +40,7 @@ import type {
import type { DatabaseAdapterResult } from '../database/types.js'
import type { EmailAdapter, SendEmailOptions } from '../email/types.js'
import type { ErrorName } from '../errors/types.js'
import type { RootFoldersConfiguration } from '../folders/types.js'
import type { GlobalConfig, Globals, SanitizedGlobalConfig } from '../globals/config/types.js'
import type {
Block,
@@ -846,6 +847,11 @@ export type Config = {
* @default '/account'
*/
account?: `/${string}`
/** The route for the browse by folder view.
*
* @default '/browse-by-folder'
*/
browseByFolder: `/${string}`
/** The route for the create first user page.
*
* @default '/create-first-user'
@@ -977,6 +983,11 @@ export type Config = {
email?: EmailAdapter | Promise<EmailAdapter>
/** Custom REST endpoints */
endpoints?: Endpoint[]
/**
* Options for folder view within the admin panel
* @experimental this feature may change in minor versions until it is fully stable
*/
folders?: RootFoldersConfiguration
/**
* @see https://payloadcms.com/docs/configuration/globals#global-configs
*/

View File

@@ -41,11 +41,24 @@ export { getFieldPaths } from '../fields/getFieldPaths.js'
export * from '../fields/validations.js'
export type {
FolderBreadcrumb,
FolderDocumentItemKey,
FolderEnabledColection,
FolderInterface,
FolderOrDocument,
GetFolderDataResult,
Subfolder,
} from '../folders/types.js'
export { formatFolderOrDocumentItem } from '../folders/utils/formatFolderOrDocumentItem.js'
export { validOperators, validOperatorSet } from '../types/constants.js'
export { formatFilesize } from '../uploads/formatFilesize.js'
export { isImage } from '../uploads/isImage.js'
export { combineWhereConstraints } from '../utilities/combineWhereConstraints.js'
export {
deepCopyObject,
deepCopyObjectComplex,
@@ -60,6 +73,7 @@ export {
deepMergeWithSourceArrays,
} from '../utilities/deepMerge.js'
export { extractID } from '../utilities/extractID.js'
export { fieldSchemaToJSON } from '../utilities/fieldSchemaToJSON.js'
export { flattenAllFields } from '../utilities/flattenAllFields.js'
@@ -112,4 +126,5 @@ export { validateWhereQuery } from '../utilities/validateWhereQuery.js'
export { wait } from '../utilities/wait.js'
export { default as wordBoundariesRegex } from '../utilities/wordBoundariesRegex.js'
export { versionDefaults } from '../versions/defaults.js'
export { deepMergeSimple } from '@payloadcms/translations/utilities'

View File

@@ -0,0 +1,58 @@
import type { Config } from '../config/types.js'
import type { CollectionSlug } from '../index.js'
import { createFolderCollection } from './createFolderCollection.js'
export async function addFolderCollections(config: NonNullable<Config>): Promise<void> {
if (!config.collections) {
return
}
const enabledCollectionSlugs: CollectionSlug[] = []
const debug = Boolean(config?.folders?.debug)
const folderFieldName = config?.folders?.fieldName as unknown as string
const folderSlug = config?.folders?.slug as unknown as CollectionSlug
for (let i = 0; i < config.collections.length; i++) {
const collection = config.collections[i]
if (collection?.admin?.folders) {
if (collection) {
collection.fields.push({
name: folderFieldName,
type: 'relationship',
admin: {
allowCreate: false,
allowEdit: false,
components: {
Cell: '@payloadcms/ui/rsc#FolderTableCell',
Field: '@payloadcms/ui/rsc#FolderEditField',
},
},
index: true,
label: 'Folder',
relationTo: folderSlug,
})
enabledCollectionSlugs.push(collection.slug)
}
}
}
if (enabledCollectionSlugs.length) {
let folderCollection = createFolderCollection({
slug: folderSlug,
collectionSlugs: enabledCollectionSlugs,
debug,
folderFieldName,
})
if (
Array.isArray(config?.folders?.collectionOverrides) &&
config?.folders.collectionOverrides.length
) {
for (const override of config.folders.collectionOverrides) {
folderCollection = await override({ collection: folderCollection })
}
}
config.collections.push(folderCollection)
}
}

View File

@@ -0,0 +1,2 @@
export const foldersSlug = 'payload-folders'
export const parentFolderFieldName = 'folder'

View File

@@ -0,0 +1,74 @@
import type { CollectionConfig } from '../collections/config/types.js'
import { populateFolderDataEndpoint } from './endpoints/populateFolderData.js'
import { deleteSubfoldersAfterDelete } from './hooks/deleteSubfoldersAfterDelete.js'
import { dissasociateAfterDelete } from './hooks/dissasociateAfterDelete.js'
import { reparentChildFolder } from './hooks/reparentChildFolder.js'
type CreateFolderCollectionArgs = {
collectionSlugs: string[]
debug?: boolean
folderFieldName: string
slug: string
}
export const createFolderCollection = ({
slug,
collectionSlugs,
debug,
folderFieldName,
}: CreateFolderCollectionArgs): CollectionConfig => ({
slug,
admin: {
hidden: !debug,
useAsTitle: 'name',
},
endpoints: [populateFolderDataEndpoint],
fields: [
{
name: 'name',
type: 'text',
index: true,
required: true,
},
{
name: folderFieldName,
type: 'relationship',
admin: {
hidden: !debug,
},
index: true,
relationTo: slug,
},
{
name: 'documentsAndFolders',
type: 'join',
admin: {
hidden: !debug,
},
collection: [slug, ...collectionSlugs],
hasMany: true,
on: folderFieldName,
},
],
hooks: {
afterChange: [
reparentChildFolder({
folderFieldName,
}),
],
afterDelete: [
dissasociateAfterDelete({
collectionSlugs,
folderFieldName,
}),
deleteSubfoldersAfterDelete({ folderFieldName, folderSlug: slug }),
],
},
labels: {
plural: 'Folders',
singular: 'Folder',
},
typescript: {
interface: 'FolderInterface',
},
})

View File

@@ -0,0 +1,45 @@
import httpStatus from 'http-status'
import type { Endpoint } from '../../index.js'
import { getFolderData } from '../utils/getFolderData.js'
export const populateFolderDataEndpoint: Endpoint = {
handler: async (req) => {
if (!req?.user) {
return Response.json(
{
message: 'Unauthorized request.',
},
{
status: httpStatus.UNAUTHORIZED,
},
)
}
const folderCollection = Boolean(req.payload.collections?.[req.payload.config.folders.slug])
if (!folderCollection) {
return Response.json(
{
message: 'Folders are not configured',
},
{
status: httpStatus.NOT_FOUND,
},
)
}
const data = await getFolderData({
collectionSlug: req.searchParams?.get('collectionSlug') || undefined,
folderID: req.searchParams?.get('folderID') || undefined,
payload: req.payload,
search: req.searchParams?.get('search') || undefined,
user: req.user,
})
return Response.json(data)
},
method: 'get',
path: '/populate-folder-data',
}

View File

@@ -0,0 +1,22 @@
import type { CollectionAfterDeleteHook } from '../../index.js'
type Args = {
folderFieldName: string
folderSlug: string
}
export const deleteSubfoldersAfterDelete = ({
folderFieldName,
folderSlug,
}: Args): CollectionAfterDeleteHook => {
return async ({ id, req }) => {
await req.payload.delete({
collection: folderSlug,
req,
where: {
[folderFieldName]: {
equals: id,
},
},
})
}
}

View File

@@ -0,0 +1,27 @@
import type { CollectionAfterDeleteHook } from '../../index.js'
type Args = {
collectionSlugs: string[]
folderFieldName: string
}
export const dissasociateAfterDelete = ({
collectionSlugs,
folderFieldName,
}: Args): CollectionAfterDeleteHook => {
return async ({ id, req }) => {
for (const collectionSlug of collectionSlugs) {
await req.payload.update({
collection: collectionSlug,
data: {
[folderFieldName]: null,
},
req,
where: {
[folderFieldName]: {
equals: id,
},
},
})
}
}
}

View File

@@ -0,0 +1,101 @@
import type { CollectionAfterChangeHook, Payload } from '../../index.js'
import { extractID } from '../../utilities/extractID.js'
type Args = {
folderFieldName: string
folderID: number | string
parentIDToFind: number | string
payload: Payload
}
/**
* Determines if a child folder belongs to a parent folder by
* recursively checking upwards through the folder hierarchy.
*/
async function isChildOfFolder({
folderFieldName,
folderID,
parentIDToFind,
payload,
}: Args): Promise<boolean> {
const parentFolder = await payload.findByID({
id: folderID,
collection: payload.config.folders.slug,
})
const parentFolderID = parentFolder[folderFieldName]
? extractID(parentFolder[folderFieldName])
: undefined
if (!parentFolderID) {
// made it to the root
return false
}
if (parentFolderID === parentIDToFind) {
// found match, would be cyclic
return true
}
return isChildOfFolder({
folderFieldName,
folderID: parentFolderID,
parentIDToFind,
payload,
})
}
/**
* If a parent is moved into a child folder, we need to re-parent the child
*
* @example
*
* ```ts
→ F1
→ F2
→ F2A
→ F3
Moving F1 → F2A becomes:
→ F2A
→ F1
→ F2
→ F3
```
*/
export const reparentChildFolder = ({
folderFieldName,
}: {
folderFieldName: string
}): CollectionAfterChangeHook => {
return async ({ doc, previousDoc, req }) => {
if (previousDoc[folderFieldName] !== doc[folderFieldName] && doc[folderFieldName]) {
const newParentFolderID = extractID(doc[folderFieldName])
const isMovingToChild = newParentFolderID
? await isChildOfFolder({
folderFieldName,
folderID: newParentFolderID,
parentIDToFind: doc.id,
payload: req.payload,
})
: false
if (isMovingToChild) {
// if the folder was moved into a child folder, the child folder needs
// to be re-parented with the parent of the folder that was moved
await req.payload.update({
id: newParentFolderID,
collection: req.payload.config.folders.slug,
data: {
[folderFieldName]: previousDoc[folderFieldName]
? extractID(previousDoc[folderFieldName])
: null,
},
req,
})
}
}
}
}

View File

@@ -0,0 +1,102 @@
import type { CollectionConfig, TypeWithID } from '../collections/config/types.js'
import type { CollectionSlug, SanitizedCollectionConfig } from '../index.js'
import type { Document } from '../types/index.js'
export type FolderInterface = {
documentsAndFolders?: {
docs: {
relationTo: CollectionSlug
value: Document
}[]
}
folder?: FolderInterface | (number | string | undefined)
name: string
} & TypeWithID
export type FolderBreadcrumb = {
id: null | number | string
name: string
}
export type Subfolder = {
fileCount: number
hasSubfolders: boolean
id: number | string
name: string
subfolderCount: number
}
export type FolderEnabledColection = {
admin: {
custom: {
folderCollectionSlug: CollectionSlug
}
}
slug: CollectionSlug
} & SanitizedCollectionConfig
/**
* `${relationTo}-${id}` is used as a key for the item
*/
export type FolderDocumentItemKey = `${string}-${number | string}`
/**
* Needed for document card view for upload enabled collections
*/
type DocumentMediaData = {
filename?: string
mimeType?: string
url?: string
}
/**
* A generic structure for a folder or document item.
*/
export type FolderOrDocument = {
itemKey: FolderDocumentItemKey
relationTo: CollectionSlug
value: {
_folderOrDocumentTitle: string
createdAt?: string
folderID?: number | string
id: number | string
updatedAt?: string
} & DocumentMediaData
}
export type GetFolderDataResult = {
breadcrumbs: FolderBreadcrumb[] | null
documents: FolderOrDocument[]
subfolders: FolderOrDocument[]
}
export type RootFoldersConfiguration = {
/**
* An array of functions to be ran when the folder collection is initialized
* This allows plugins to modify the collection configuration
*/
collectionOverrides?: (({
collection,
}: {
collection: CollectionConfig
}) => CollectionConfig | Promise<CollectionConfig>)[]
/**
* Ability to view hidden fields and collections related to folders
*
* @default false
*/
debug?: boolean
/**
* The Folder field name
*
* @default "folder"
*/
fieldName?: string
/**
* Slug for the folder collection
*
* @default "payload-folders"
*/
slug?: string
}
export type CollectionFoldersConfiguration = boolean

View File

@@ -0,0 +1,37 @@
import type { CollectionSlug, Document } from '../../index.js'
import type { FolderOrDocument } from '../types.js'
type Args = {
folderFieldName: string
isUpload: boolean
relationTo: CollectionSlug
useAsTitle?: string
value: Document
}
export function formatFolderOrDocumentItem({
folderFieldName,
isUpload,
relationTo,
useAsTitle,
value,
}: Args): FolderOrDocument {
const itemValue: FolderOrDocument['value'] = {
id: value?.id,
_folderOrDocumentTitle: (useAsTitle && value?.[useAsTitle]) || value['id'],
createdAt: value?.createdAt,
folderID: value?.[folderFieldName],
updatedAt: value?.updatedAt,
}
if (isUpload) {
itemValue.filename = value.filename
itemValue.mimeType = value.mimeType
itemValue.url = value.url
}
return {
itemKey: `${relationTo}-${value.id}`,
relationTo,
value: itemValue,
}
}

View File

@@ -0,0 +1,63 @@
import type { User } from '../../index.js'
import type { Document, Payload } from '../../types/index.js'
import type { FolderBreadcrumb } from '../types.js'
type GetFolderBreadcrumbsArgs = {
breadcrumbs?: FolderBreadcrumb[]
folderID?: number | string
payload: Payload
user?: User
}
/**
* Builds breadcrumbs up from child folder
* all the way up to root folder
*/
export const getFolderBreadcrumbs = async ({
breadcrumbs = [],
folderID,
payload,
user,
}: GetFolderBreadcrumbsArgs): Promise<FolderBreadcrumb[] | null> => {
const folderFieldName: string = payload.config.folders.fieldName
if (folderID) {
const folderQuery = await payload.find({
collection: payload.config.folders.slug,
depth: 0,
limit: 1,
overrideAccess: false,
select: {
name: true,
[folderFieldName]: true,
},
user,
where: {
id: {
equals: folderID,
},
},
})
const folder = folderQuery.docs[0] as Document
if (folder) {
breadcrumbs.push({
id: folder.id,
name: folder.name,
})
if (folder[folderFieldName]) {
return getFolderBreadcrumbs({
breadcrumbs,
folderID:
typeof folder[folderFieldName] === 'number' ||
typeof folder[folderFieldName] === 'string'
? folder[folderFieldName]
: folder[folderFieldName].id,
payload,
user,
})
}
}
}
return breadcrumbs.reverse()
}

View File

@@ -0,0 +1,104 @@
import type { CollectionSlug, User } from '../../index.js'
import type { Payload } from '../../types/index.js'
import type { GetFolderDataResult } from '../types.js'
import { parseDocumentID } from '../../index.js'
import { getFolderBreadcrumbs } from './getFolderBreadcrumbs.js'
import { queryDocumentsAndFoldersFromJoin } from './getFoldersAndDocumentsFromJoin.js'
import { getOrphanedDocs } from './getOrphanedDocs.js'
type Args = {
/**
* Specify to query documents from a specific collection
* @default undefined
* @example 'posts'
*/
collectionSlug?: CollectionSlug
/**
* The ID of the folder to query documents from
* @default undefined
*/
folderID?: number | string
/**
* The locale to use for the document query
* @default undefined
*/
payload: Payload
/**
* Search term to filter documents by - only applicable IF `collectionSlug` exists and NO `folderID` is provided
*/
search?: string
/**
* The user making the request
* @default undefined
*/
user?: User
}
/**
* Query for documents, subfolders and breadcrumbs for a given folder
*/
export const getFolderData = async ({
collectionSlug,
folderID: _folderID,
payload,
search,
user,
}: Args): Promise<GetFolderDataResult> => {
const parentFolderID = parseDocumentID({
id: _folderID,
collectionSlug: payload.config.folders.slug,
payload,
})
const breadcrumbsPromise = getFolderBreadcrumbs({
folderID: parentFolderID,
payload,
user,
})
if (parentFolderID) {
// subfolders and documents are queried together
const documentAndSubfolderPromise = queryDocumentsAndFoldersFromJoin({
collectionSlug,
parentFolderID,
payload,
user,
})
const [breadcrumbs, documentsAndSubfolders] = await Promise.all([
breadcrumbsPromise,
documentAndSubfolderPromise,
])
return {
breadcrumbs,
documents: documentsAndSubfolders.documents,
subfolders: documentsAndSubfolders.subfolders,
}
} else {
// subfolders and documents are queried separately
const subfoldersPromise = getOrphanedDocs({
collectionSlug: payload.config.folders.slug,
payload,
search,
user,
})
const documentsPromise = collectionSlug
? getOrphanedDocs({
collectionSlug,
payload,
search,
user,
})
: Promise.resolve([])
const [breadcrumbs, subfolders, documents] = await Promise.all([
breadcrumbsPromise,
subfoldersPromise,
documentsPromise,
])
return {
breadcrumbs,
documents,
subfolders,
}
}
}

View File

@@ -0,0 +1,89 @@
import type { User } from '../../auth/types.js'
import type { PaginatedDocs } from '../../database/types.js'
import type { CollectionSlug } from '../../index.js'
import type { Document, Payload } from '../../types/index.js'
import type { FolderOrDocument } from '../types.js'
import { formatFolderOrDocumentItem } from './formatFolderOrDocumentItem.js'
type QueryDocumentsAndFoldersResults = {
documents: FolderOrDocument[]
subfolders: FolderOrDocument[]
}
type QueryDocumentsAndFoldersArgs = {
collectionSlug?: CollectionSlug
parentFolderID: number | string
payload: Payload
user?: User
}
export async function queryDocumentsAndFoldersFromJoin({
collectionSlug,
parentFolderID,
payload,
user,
}: QueryDocumentsAndFoldersArgs): Promise<QueryDocumentsAndFoldersResults> {
const folderCollectionSlugs: string[] = payload.config.collections.reduce<string[]>(
(acc, collection) => {
if (collection?.admin?.folders) {
acc.push(collection.slug)
}
return acc
},
[],
)
const subfolderDoc = (await payload.find({
collection: payload.config.folders.slug,
joins: {
documentsAndFolders: {
limit: 100_000,
sort: 'name',
where: {
relationTo: {
in: [
payload.config.folders.slug,
...(collectionSlug ? [collectionSlug] : folderCollectionSlugs),
],
},
},
},
},
limit: 1,
overrideAccess: false,
user,
where: {
id: {
equals: parentFolderID,
},
},
})) as PaginatedDocs<Document>
const childrenDocs = subfolderDoc?.docs[0]?.documentsAndFolders?.docs || []
const results: QueryDocumentsAndFoldersResults = childrenDocs.reduce(
(acc: QueryDocumentsAndFoldersResults, doc: Document) => {
const { relationTo, value } = doc
const item = formatFolderOrDocumentItem({
folderFieldName: payload.config.folders.fieldName,
isUpload: Boolean(payload.collections[relationTo].config.upload),
relationTo,
useAsTitle: payload.collections[relationTo].config.admin?.useAsTitle,
value,
})
if (relationTo === payload.config.folders.slug) {
acc.subfolders.push(item)
} else {
acc.documents.push(item)
}
return acc
},
{
documents: [],
subfolders: [],
},
)
return results
}

View File

@@ -0,0 +1,61 @@
import type { CollectionSlug, Payload, User, Where } from '../../index.js'
import type { FolderOrDocument } from '../types.js'
import { formatFolderOrDocumentItem } from './formatFolderOrDocumentItem.js'
type Args = {
collectionSlug: CollectionSlug
payload: Payload
search?: string
user?: User
}
export async function getOrphanedDocs({
collectionSlug,
payload,
search,
user,
}: Args): Promise<FolderOrDocument[]> {
let whereConstraints: Where = {
or: [
{
[payload.config.folders.fieldName]: {
exists: false,
},
},
{
[payload.config.folders.fieldName]: {
equals: null,
},
},
],
}
if (collectionSlug && search && payload.collections[collectionSlug].config.admin?.useAsTitle) {
whereConstraints = {
[payload.collections[collectionSlug].config.admin.useAsTitle]: {
like: search,
},
}
}
const orphanedFolders = await payload.find({
collection: collectionSlug,
limit: 0,
overrideAccess: false,
sort: payload.collections[collectionSlug].config.admin.useAsTitle,
user,
where: whereConstraints,
})
return (
orphanedFolders?.docs.map((doc) =>
formatFolderOrDocumentItem({
folderFieldName: payload.config.folders.fieldName,
isUpload: Boolean(payload.collections[collectionSlug].config.upload),
relationTo: collectionSlug,
useAsTitle: payload.collections[collectionSlug].config.admin.useAsTitle,
value: doc,
}),
) || []
)
}

View File

@@ -1371,6 +1371,7 @@ export type {
UploadFieldValidation,
UsernameFieldValidation,
} from './fields/validations.js'
export { getFolderData } from './folders/utils/getFolderData.js'
export {
type ClientGlobalConfig,
createClientGlobalConfig,
@@ -1378,7 +1379,6 @@ export {
type ServerOnlyGlobalAdminProperties,
type ServerOnlyGlobalProperties,
} from './globals/config/client.js'
export type {
AfterChangeHook as GlobalAfterChangeHook,
AfterReadHook as GlobalAfterReadHook,
@@ -1390,7 +1390,6 @@ export type {
GlobalConfig,
SanitizedGlobalConfig,
} from './globals/config/types.js'
export { docAccessOperation as docAccessOperationGlobal } from './globals/operations/docAccess.js'
export { findOneOperation } from './globals/operations/findOne.js'
@@ -1437,11 +1436,11 @@ export type {
WorkflowTypes,
} from './queues/config/types/workflowTypes.js'
export { importHandlerPath } from './queues/operations/runJobs/runJob/importHandlerPath.js'
export { getLocalI18n } from './translations/getLocalI18n.js'
export * from './types/index.js'
export { getFileByPath } from './uploads/getFileByPath.js'
export type * from './uploads/types.js'
export { addDataAndFileToRequest } from './utilities/addDataAndFileToRequest.js'
export { addLocalesToRequestFromData, sanitizeLocales } from './utilities/addLocalesToRequest.js'
@@ -1499,6 +1498,7 @@ export { logError } from './utilities/logError.js'
export { defaultLoggerOptions } from './utilities/logger.js'
export { mapAsync } from './utilities/mapAsync.js'
export { mergeHeaders } from './utilities/mergeHeaders.js'
export { parseDocumentID } from './utilities/parseDocumentID.js'
export { sanitizeFallbackLocale } from './utilities/sanitizeFallbackLocale.js'
export { sanitizeJoinParams } from './utilities/sanitizeJoinParams.js'
export { sanitizePopulateParam } from './utilities/sanitizePopulateParam.js'
@@ -1515,6 +1515,7 @@ export { appendVersionToQueryKey } from './versions/drafts/appendVersionToQueryK
export { getQueryDraftsSort } from './versions/drafts/getQueryDraftsSort.js'
export { enforceMaxVersions } from './versions/enforceMaxVersions.js'
export { getLatestCollectionVersion } from './versions/getLatestCollectionVersion.js'
export { getLatestGlobalVersion } from './versions/getLatestGlobalVersion.js'
export { saveVersion } from './versions/saveVersion.js'
export type { SchedulePublishTaskInput } from './versions/schedule/types.js'

View File

@@ -0,0 +1,19 @@
import type { Where } from '../types/index.js'
export function combineWhereConstraints(
constraints: Array<undefined | Where>,
as: 'and' | 'or' = 'and',
): Where {
if (constraints.length === 0) {
return {}
}
return {
[as]: constraints.filter((constraint): constraint is Where => {
if (constraint && typeof constraint === 'object' && Object.keys(constraint).length > 0) {
return true
}
return false
}),
}
}

View File

@@ -0,0 +1,9 @@
export const extractID = <IDType extends number | string>(
objectOrID: { id: IDType } | IDType,
): IDType => {
if (typeof objectOrID === 'string' || typeof objectOrID === 'number') {
return objectOrID
}
return objectOrID.id
}

View File

@@ -0,0 +1,15 @@
import type { CollectionSlug, Payload } from '../index.js'
import { isNumber } from './isNumber.js'
type ParseDocumentIDArgs = {
collectionSlug: CollectionSlug
id?: number | string
payload: Payload
}
export function parseDocumentID({ id, collectionSlug, payload }: ParseDocumentIDArgs) {
const idType = payload.collections[collectionSlug]?.customIDType ?? payload.db.defaultIDType
return id ? (idType === 'number' && isNumber(id) ? parseFloat(String(id)) : id) : undefined
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/plugin-import-export",
"version": "3.38.0",
"version": "3.39.1",
"description": "Import-Export plugin for Payload",
"keywords": [
"payload",

View File

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

View File

@@ -21,7 +21,7 @@ export const WatchTenantCollection = () => {
const [useAsTitleName] = React.useState(
() => (getEntityConfig({ collectionSlug }) as ClientCollectionConfig).admin.useAsTitle,
)
const titleField = useFormFields(([fields]) => fields[useAsTitleName])
const titleField = useFormFields(([fields]) => (useAsTitleName ? fields[useAsTitleName] : {}))
const { updateTenants } = useTenantSelection()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "3.38.0",
"version": "3.39.1",
"description": "The officially supported Lexical richtext adapter for Payload",
"homepage": "https://payloadcms.com",
"repository": {
@@ -370,6 +370,7 @@
"@types/uuid": "10.0.0",
"acorn": "8.12.1",
"bson-objectid": "2.0.4",
"csstype": "3.1.3",
"dequal": "2.0.3",
"escape-html": "1.0.3",
"jsox": "1.2.121",

View File

@@ -20,6 +20,7 @@ export { StrikethroughFeatureClient } from '../../features/format/strikethrough/
export { SubscriptFeatureClient } from '../../features/format/subscript/feature.client.js'
export { SuperscriptFeatureClient } from '../../features/format/superscript/feature.client.js'
export { UnderlineFeatureClient } from '../../features/format/underline/feature.client.js'
export { TextStateFeatureClient } from '../../features/textState/feature.client.js'
export { HeadingFeatureClient } from '../../features/heading/client/index.js'
export { HorizontalRuleFeatureClient } from '../../features/horizontalRule/client/index.js'
export { IndentFeatureClient } from '../../features/indent/client/index.js'

View File

@@ -88,12 +88,12 @@ const nativeMax = Math.max,
* @category Function
* @param {Function} func The function to debounce.
* @param {number} [wait=0] The number of milliseconds to delay.
* @param {Object} [options={}] The options object.
* @param {boolean} [options.leading=false]
* @param {Object} [sortOnOptions={}] The options object.
* @param {boolean} [sortOnOptions.leading=false]
* Specify invoking on the leading edge of the timeout.
* @param {number} [options.maxWait]
* @param {number} [sortOnOptions.maxWait]
* The maximum time `func` is allowed to be delayed before it's invoked.
* @param {boolean} [options.trailing=true]
* @param {boolean} [sortOnOptions.trailing=true]
* Specify invoking on the trailing edge of the timeout.
* @returns {Function} Returns the new debounced function.
* @example

View File

@@ -0,0 +1,318 @@
import type { StateValues } from './feature.server.js'
const tailwindColors = {
amber: {
'50': 'oklch(0.987 0.022 95.277)',
'100': 'oklch(0.962 0.059 95.617)',
'200': 'oklch(0.924 0.12 95.746)',
'300': 'oklch(0.879 0.169 91.605)',
'400': 'oklch(0.828 0.189 84.429)',
'500': 'oklch(0.769 0.188 70.08)',
'600': 'oklch(0.666 0.179 58.318)',
'700': 'oklch(0.555 0.163 48.998)',
'800': 'oklch(0.473 0.137 46.201)',
'900': 'oklch(0.414 0.112 45.904)',
'950': 'oklch(0.279 0.077 45.635)',
},
black: '#000',
blue: {
'50': 'oklch(0.97 0.014 254.604)',
'100': 'oklch(0.932 0.032 255.585)',
'200': 'oklch(0.882 0.059 254.128)',
'300': 'oklch(0.809 0.105 251.813)',
'400': 'oklch(0.707 0.165 254.624)',
'500': 'oklch(0.623 0.214 259.815)',
'600': 'oklch(0.546 0.245 262.881)',
'700': 'oklch(0.488 0.243 264.376)',
'800': 'oklch(0.424 0.199 265.638)',
'900': 'oklch(0.379 0.146 265.522)',
'950': 'oklch(0.282 0.091 267.935)',
},
current: 'currentColor',
cyan: {
'50': 'oklch(0.984 0.019 200.873)',
'100': 'oklch(0.956 0.045 203.388)',
'200': 'oklch(0.917 0.08 205.041)',
'300': 'oklch(0.865 0.127 207.078)',
'400': 'oklch(0.789 0.154 211.53)',
'500': 'oklch(0.715 0.143 215.221)',
'600': 'oklch(0.609 0.126 221.723)',
'700': 'oklch(0.52 0.105 223.128)',
'800': 'oklch(0.45 0.085 224.283)',
'900': 'oklch(0.398 0.07 227.392)',
'950': 'oklch(0.302 0.056 229.695)',
},
emerald: {
'50': 'oklch(0.979 0.021 166.113)',
'100': 'oklch(0.95 0.052 163.051)',
'200': 'oklch(0.905 0.093 164.15)',
'300': 'oklch(0.845 0.143 164.978)',
'400': 'oklch(0.765 0.177 163.223)',
'500': 'oklch(0.696 0.17 162.48)',
'600': 'oklch(0.596 0.145 163.225)',
'700': 'oklch(0.508 0.118 165.612)',
'800': 'oklch(0.432 0.095 166.913)',
'900': 'oklch(0.378 0.077 168.94)',
'950': 'oklch(0.262 0.051 172.552)',
},
fuchsia: {
'50': 'oklch(0.977 0.017 320.058)',
'100': 'oklch(0.952 0.037 318.852)',
'200': 'oklch(0.903 0.076 319.62)',
'300': 'oklch(0.833 0.145 321.434)',
'400': 'oklch(0.74 0.238 322.16)',
'500': 'oklch(0.667 0.295 322.15)',
'600': 'oklch(0.591 0.293 322.896)',
'700': 'oklch(0.518 0.253 323.949)',
'800': 'oklch(0.452 0.211 324.591)',
'900': 'oklch(0.401 0.17 325.612)',
'950': 'oklch(0.293 0.136 325.661)',
},
gray: {
'50': 'oklch(0.985 0.002 247.839)',
'100': 'oklch(0.967 0.003 264.542)',
'200': 'oklch(0.928 0.006 264.531)',
'300': 'oklch(0.872 0.01 258.338)',
'400': 'oklch(0.707 0.022 261.325)',
'500': 'oklch(0.551 0.027 264.364)',
'600': 'oklch(0.446 0.03 256.802)',
'700': 'oklch(0.373 0.034 259.733)',
'800': 'oklch(0.278 0.033 256.848)',
'900': 'oklch(0.21 0.034 264.665)',
'950': 'oklch(0.13 0.028 261.692)',
},
green: {
'50': 'oklch(0.982 0.018 155.826)',
'100': 'oklch(0.962 0.044 156.743)',
'200': 'oklch(0.925 0.084 155.995)',
'300': 'oklch(0.871 0.15 154.449)',
'400': 'oklch(0.792 0.209 151.711)',
'500': 'oklch(0.723 0.219 149.579)',
'600': 'oklch(0.627 0.194 149.214)',
'700': 'oklch(0.527 0.154 150.069)',
'800': 'oklch(0.448 0.119 151.328)',
'900': 'oklch(0.393 0.095 152.535)',
'950': 'oklch(0.266 0.065 152.934)',
},
indigo: {
'50': 'oklch(0.962 0.018 272.314)',
'100': 'oklch(0.93 0.034 272.788)',
'200': 'oklch(0.87 0.065 274.039)',
'300': 'oklch(0.785 0.115 274.713)',
'400': 'oklch(0.673 0.182 276.935)',
'500': 'oklch(0.585 0.233 277.117)',
'600': 'oklch(0.511 0.262 276.966)',
'700': 'oklch(0.457 0.24 277.023)',
'800': 'oklch(0.398 0.195 277.366)',
'900': 'oklch(0.359 0.144 278.697)',
'950': 'oklch(0.257 0.09 281.288)',
},
inherit: 'inherit',
lime: {
'50': 'oklch(0.986 0.031 120.757)',
'100': 'oklch(0.967 0.067 122.328)',
'200': 'oklch(0.938 0.127 124.321)',
'300': 'oklch(0.897 0.196 126.665)',
'400': 'oklch(0.841 0.238 128.85)',
'500': 'oklch(0.768 0.233 130.85)',
'600': 'oklch(0.648 0.2 131.684)',
'700': 'oklch(0.532 0.157 131.589)',
'800': 'oklch(0.453 0.124 130.933)',
'900': 'oklch(0.405 0.101 131.063)',
'950': 'oklch(0.274 0.072 132.109)',
},
neutral: {
'50': 'oklch(0.985 0 0)',
'100': 'oklch(0.97 0 0)',
'200': 'oklch(0.922 0 0)',
'300': 'oklch(0.87 0 0)',
'400': 'oklch(0.708 0 0)',
'500': 'oklch(0.556 0 0)',
'600': 'oklch(0.439 0 0)',
'700': 'oklch(0.371 0 0)',
'800': 'oklch(0.269 0 0)',
'900': 'oklch(0.205 0 0)',
'950': 'oklch(0.145 0 0)',
},
orange: {
'50': 'oklch(0.98 0.016 73.684)',
'100': 'oklch(0.954 0.038 75.164)',
'200': 'oklch(0.901 0.076 70.697)',
'300': 'oklch(0.837 0.128 66.29)',
'400': 'oklch(0.75 0.183 55.934)',
'500': 'oklch(0.705 0.213 47.604)',
'600': 'oklch(0.646 0.222 41.116)',
'700': 'oklch(0.553 0.195 38.402)',
'800': 'oklch(0.47 0.157 37.304)',
'900': 'oklch(0.408 0.123 38.172)',
'950': 'oklch(0.266 0.079 36.259)',
},
pink: {
'50': 'oklch(0.971 0.014 343.198)',
'100': 'oklch(0.948 0.028 342.258)',
'200': 'oklch(0.899 0.061 343.231)',
'300': 'oklch(0.823 0.12 346.018)',
'400': 'oklch(0.718 0.202 349.761)',
'500': 'oklch(0.656 0.241 354.308)',
'600': 'oklch(0.592 0.249 0.584)',
'700': 'oklch(0.525 0.223 3.958)',
'800': 'oklch(0.459 0.187 3.815)',
'900': 'oklch(0.408 0.153 2.432)',
'950': 'oklch(0.284 0.109 3.907)',
},
purple: {
'50': 'oklch(0.977 0.014 308.299)',
'100': 'oklch(0.946 0.033 307.174)',
'200': 'oklch(0.902 0.063 306.703)',
'300': 'oklch(0.827 0.119 306.383)',
'400': 'oklch(0.714 0.203 305.504)',
'500': 'oklch(0.627 0.265 303.9)',
'600': 'oklch(0.558 0.288 302.321)',
'700': 'oklch(0.496 0.265 301.924)',
'800': 'oklch(0.438 0.218 303.724)',
'900': 'oklch(0.381 0.176 304.987)',
'950': 'oklch(0.291 0.149 302.717)',
},
red: {
'50': 'oklch(0.971 0.013 17.38)',
'100': 'oklch(0.936 0.032 17.717)',
'200': 'oklch(0.885 0.062 18.334)',
'300': 'oklch(0.808 0.114 19.571)',
'400': 'oklch(0.704 0.191 22.216)',
'500': 'oklch(0.637 0.237 25.331)',
'600': 'oklch(0.577 0.245 27.325)',
'700': 'oklch(0.505 0.213 27.518)',
'800': 'oklch(0.444 0.177 26.899)',
'900': 'oklch(0.396 0.141 25.723)',
'950': 'oklch(0.258 0.092 26.042)',
},
rose: {
'50': 'oklch(0.969 0.015 12.422)',
'100': 'oklch(0.941 0.03 12.58)',
'200': 'oklch(0.892 0.058 10.001)',
'300': 'oklch(0.81 0.117 11.638)',
'400': 'oklch(0.712 0.194 13.428)',
'500': 'oklch(0.645 0.246 16.439)',
'600': 'oklch(0.586 0.253 17.585)',
'700': 'oklch(0.514 0.222 16.935)',
'800': 'oklch(0.455 0.188 13.697)',
'900': 'oklch(0.41 0.159 10.272)',
'950': 'oklch(0.271 0.105 12.094)',
},
sky: {
'50': 'oklch(0.977 0.013 236.62)',
'100': 'oklch(0.951 0.026 236.824)',
'200': 'oklch(0.901 0.058 230.902)',
'300': 'oklch(0.828 0.111 230.318)',
'400': 'oklch(0.746 0.16 232.661)',
'500': 'oklch(0.685 0.169 237.323)',
'600': 'oklch(0.588 0.158 241.966)',
'700': 'oklch(0.5 0.134 242.749)',
'800': 'oklch(0.443 0.11 240.79)',
'900': 'oklch(0.391 0.09 240.876)',
'950': 'oklch(0.293 0.066 243.157)',
},
slate: {
'50': 'oklch(0.984 0.003 247.858)',
'100': 'oklch(0.968 0.007 247.896)',
'200': 'oklch(0.929 0.013 255.508)',
'300': 'oklch(0.869 0.022 252.894)',
'400': 'oklch(0.704 0.04 256.788)',
'500': 'oklch(0.554 0.046 257.417)',
'600': 'oklch(0.446 0.043 257.281)',
'700': 'oklch(0.372 0.044 257.287)',
'800': 'oklch(0.279 0.041 260.031)',
'900': 'oklch(0.208 0.042 265.755)',
'950': 'oklch(0.129 0.042 264.695)',
},
stone: {
'50': 'oklch(0.985 0.001 106.423)',
'100': 'oklch(0.97 0.001 106.424)',
'200': 'oklch(0.923 0.003 48.717)',
'300': 'oklch(0.869 0.005 56.366)',
'400': 'oklch(0.709 0.01 56.259)',
'500': 'oklch(0.553 0.013 58.071)',
'600': 'oklch(0.444 0.011 73.639)',
'700': 'oklch(0.374 0.01 67.558)',
'800': 'oklch(0.268 0.007 34.298)',
'900': 'oklch(0.216 0.006 56.043)',
'950': 'oklch(0.147 0.004 49.25)',
},
teal: {
'50': 'oklch(0.984 0.014 180.72)',
'100': 'oklch(0.953 0.051 180.801)',
'200': 'oklch(0.91 0.096 180.426)',
'300': 'oklch(0.855 0.138 181.071)',
'400': 'oklch(0.777 0.152 181.912)',
'500': 'oklch(0.704 0.14 182.503)',
'600': 'oklch(0.6 0.118 184.704)',
'700': 'oklch(0.511 0.096 186.391)',
'800': 'oklch(0.437 0.078 188.216)',
'900': 'oklch(0.386 0.063 188.416)',
'950': 'oklch(0.277 0.046 192.524)',
},
transparent: 'transparent',
violet: {
'50': 'oklch(0.969 0.016 293.756)',
'100': 'oklch(0.943 0.029 294.588)',
'200': 'oklch(0.894 0.057 293.283)',
'300': 'oklch(0.811 0.111 293.571)',
'400': 'oklch(0.702 0.183 293.541)',
'500': 'oklch(0.606 0.25 292.717)',
'600': 'oklch(0.541 0.281 293.009)',
'700': 'oklch(0.491 0.27 292.581)',
'800': 'oklch(0.432 0.232 292.759)',
'900': 'oklch(0.38 0.189 293.745)',
'950': 'oklch(0.283 0.141 291.089)',
},
white: '#fff',
yellow: {
'50': 'oklch(0.987 0.026 102.212)',
'100': 'oklch(0.973 0.071 103.193)',
'200': 'oklch(0.945 0.129 101.54)',
'300': 'oklch(0.905 0.182 98.111)',
'400': 'oklch(0.852 0.199 91.936)',
'500': 'oklch(0.795 0.184 86.047)',
'600': 'oklch(0.681 0.162 75.834)',
'700': 'oklch(0.554 0.135 66.442)',
'800': 'oklch(0.476 0.114 61.907)',
'900': 'oklch(0.421 0.095 57.708)',
'950': 'oklch(0.286 0.066 53.813)',
},
zinc: {
'50': 'oklch(0.985 0 0)',
'100': 'oklch(0.967 0.001 286.375)',
'200': 'oklch(0.92 0.004 286.32)',
'300': 'oklch(0.871 0.006 286.286)',
'400': 'oklch(0.705 0.015 286.067)',
'500': 'oklch(0.552 0.016 285.938)',
'600': 'oklch(0.442 0.017 285.786)',
'700': 'oklch(0.37 0.013 285.805)',
'800': 'oklch(0.274 0.006 286.033)',
'900': 'oklch(0.21 0.006 285.885)',
'950': 'oklch(0.141 0.005 285.823)',
},
}
// prettier-ignore
/* eslint-disable perfectionist/sort-objects */
export const defaultColors = {
text: {
'text-red': { css: { 'color': `light-dark(${tailwindColors.red[600]}, ${tailwindColors.red[400]})`, }, label: 'Red' },
'text-orange': { css: { 'color': `light-dark(${tailwindColors.orange[600]}, ${tailwindColors.orange[400]})`, }, label: 'Orange' },
'text-yellow': { css: { 'color': `light-dark(${tailwindColors.yellow[700]}, ${tailwindColors.yellow[300]})`, }, label: 'Yellow' },
'text-green': { css: { 'color': `light-dark(${tailwindColors.green[700]}, ${tailwindColors.green[400]})`, }, label: 'Green' },
'text-blue': { css: { 'color': `light-dark(${tailwindColors.blue[600]}, ${tailwindColors.blue[400]})`, }, label: 'Blue' },
'text-purple': { css: { 'color': `light-dark(${tailwindColors.purple[600]}, ${tailwindColors.purple[400]})`, }, label: 'Purple' },
'text-pink': { css: { 'color': `light-dark(${tailwindColors.pink[600]}, ${tailwindColors.pink[400]})`, }, label: 'Pink' },
} satisfies StateValues,
background: {
'bg-red': { css: { 'background-color': `light-dark(${tailwindColors.red[400]}, ${tailwindColors.red[600]})`, }, label: 'Red' },
'bg-orange': { css: { 'background-color': `light-dark(${tailwindColors.orange[400]}, ${tailwindColors.orange[600]})`, }, label: 'Orange' },
'bg-yellow': { css: { 'background-color': `light-dark(${tailwindColors.yellow[300]}, ${tailwindColors.yellow[700]})`, }, label: 'Yellow' },
'bg-green': { css: { 'background-color': `light-dark(${tailwindColors.green[400]}, ${tailwindColors.green[700]})`, }, label: 'Green' },
'bg-blue': { css: { 'background-color': `light-dark(${tailwindColors.blue[400]}, ${tailwindColors.blue[600]})`, }, label: 'Blue' },
'bg-purple': { css: { 'background-color': `light-dark(${tailwindColors.purple[400]}, ${tailwindColors.purple[600]})`, }, label: 'Purple' },
'bg-pink': { css: { 'background-color': `light-dark(${tailwindColors.pink[400]}, ${tailwindColors.pink[600]})`, }, label: 'Pink' },
} satisfies StateValues
}

View File

@@ -0,0 +1,69 @@
'use client'
import type { ToolbarDropdownGroup, ToolbarGroup } from '../toolbars/types.js'
import type { TextStateFeatureProps } from './feature.server.js'
import { TextStateIcon } from '../../lexical/ui/icons/TextState/index.js'
import { createClientFeature } from '../../utilities/createClientFeature.js'
import { registerTextStates, setTextState, StatePlugin } from './textState.js'
const toolbarGroups = (props: TextStateFeatureProps): ToolbarGroup[] => {
const items: ToolbarDropdownGroup['items'] = []
for (const stateKey in props.state) {
const key = props.state[stateKey]!
for (const stateValue in key) {
const meta = key[stateValue]!
items.push({
ChildComponent: () => <TextStateIcon css={meta.css} />,
key: stateValue,
label: meta.label,
onSelect: ({ editor }) => {
setTextState(editor, stateKey, stateValue)
},
})
}
}
const clearStyle: ToolbarDropdownGroup['items'] = [
{
ChildComponent: () => <TextStateIcon />,
key: `clear-style`,
label: 'Default style',
onSelect: ({ editor }) => {
for (const stateKey in props.state) {
setTextState(editor, stateKey, undefined)
}
},
order: 1,
},
]
return [
{
type: 'dropdown',
ChildComponent: () => <TextStateIcon css={{ color: 'var(--theme-elevation-600)' }} />,
items: [...clearStyle, ...items],
key: 'textState',
order: 30,
},
]
}
export const TextStateFeatureClient = createClientFeature<TextStateFeatureProps>(({ props }) => {
registerTextStates(props.state)
return {
plugins: [
{
Component: StatePlugin,
position: 'normal',
},
],
toolbarFixed: {
groups: toolbarGroups(props),
},
toolbarInline: {
groups: toolbarGroups(props),
},
}
})

View File

@@ -0,0 +1,77 @@
import type { PropertiesHyphenFallback } from 'csstype'
import type { Prettify } from 'ts-essentials'
import { createServerFeature } from '../../utilities/createServerFeature.js'
// extracted from https://github.com/facebook/lexical/pull/7294
export type StyleObject = Prettify<{
[K in keyof PropertiesHyphenFallback]?:
| Extract<PropertiesHyphenFallback[K], string>
// This is simplified to not deal with arrays or numbers.
// This is an example after all!
| undefined
}>
export type StateValues = { [stateValue: string]: { css: StyleObject; label: string } }
export type TextStateFeatureProps = {
/**
* The keys of the top-level object (stateKeys) represent the attributes that the textNode can have (e.g., color).
* The values of the top-level object (stateValues) represent the values that the attribute can have (e.g., red, blue, etc.).
* Within the stateValue, you can define inline styles and labels.
*
* @note Because this is a common use case, we provide a defaultColors object with colors that
* look good in both dark and light mode, which you can use or adapt to your liking.
*
*
*
* @example
* import { defaultColors } from '@payloadcms/richtext-lexical'
*
* state: {
* color: {
* ...defaultColors.background,
* ...defaultColors.text,
* // fancy gradients!
* galaxy: { label: 'Galaxy', css: { background: 'linear-gradient(to right, #0000ff, #ff0000)', color: 'white' } },
* sunset: { label: 'Sunset', css: { background: 'linear-gradient(to top, #ff5f6d, #6a3093)' } },
* },
* // You can have both colored and underlined text at the same time.
* // If you don't want that, you should group them within the same key.
* // (just like I did with defaultColors and my fancy gradients)
* underline: {
* 'solid': { label: 'Solid', css: { 'text-decoration': 'underline', 'text-underline-offset': '4px' } },
* // You'll probably want to use the CSS light-dark() utility.
* 'yellow-dashed': { label: 'Yellow Dashed', css: { 'text-decoration': 'underline dashed', 'text-decoration-color': 'light-dark(#EAB308,yellow)', 'text-underline-offset': '4px' } },
* },
* }
*
*/
state: { [stateKey: string]: StateValues }
}
/**
* Allows you to store key-value attributes within TextNodes and define inline styles for each combination.
* Inline styles are not part of the editorState, reducing the JSON size and allowing you to easily migrate or adapt styles later.
*
* This feature can be used, among other things, to add colors to text.
*
* For more information and examples, see the JSdocs for the "state" property that this feature receives as a parameter.
*
* @experimental There may be breaking changes to this API
*/
export const TextStateFeature = createServerFeature<
TextStateFeatureProps,
TextStateFeatureProps,
TextStateFeatureProps
>({
feature: ({ props }) => {
return {
ClientFeature: '@payloadcms/richtext-lexical/client#TextStateFeatureClient',
clientFeatureProps: {
state: props.state,
},
}
},
key: 'textState',
})

View File

@@ -0,0 +1,80 @@
import type { LexicalEditor, StateConfig } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $forEachSelectedTextNode } from '@lexical/selection'
import { $getNodeByKey, $getState, $setState, createState, TextNode } from 'lexical'
import { useEffect } from 'react'
import { type StateValues, type TextStateFeatureProps } from './feature.server.js'
const stateMap = new Map<
string,
{
stateConfig: StateConfig<string, string | undefined>
stateValues: StateValues
}
>()
export function registerTextStates(state: TextStateFeatureProps['state']) {
for (const stateKey in state) {
const stateValues = state[stateKey]!
const stateConfig = createState(stateKey, {
parse: (value) =>
typeof value === 'string' && Object.keys(stateValues).includes(value) ? value : undefined,
})
stateMap.set(stateKey, { stateConfig, stateValues })
}
}
export function setTextState(editor: LexicalEditor, stateKey: string, value: string | undefined) {
editor.update(() => {
$forEachSelectedTextNode((textNode) => {
const stateMapEntry = stateMap.get(stateKey)
if (!stateMapEntry) {
throw new Error(`State config for ${stateKey} not found`)
}
$setState(textNode, stateMapEntry.stateConfig, value)
})
})
}
export function StatePlugin() {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return editor.registerMutationListener(TextNode, (mutatedNodes) => {
editor.getEditorState().read(() => {
for (const [nodeKey, mutation] of mutatedNodes) {
if (mutation === 'destroyed') {
continue
}
const node = $getNodeByKey(nodeKey)
const dom = editor.getElementByKey(nodeKey)
if (!node || !dom) {
continue
}
// stateKey could be color for example
stateMap.forEach((stateEntry, _stateKey) => {
// stateValue could be bg-red for example
const stateValue = $getState(node, stateEntry.stateConfig)
if (!stateValue) {
delete dom.dataset[_stateKey]
dom.style.cssText = ''
return
}
dom.dataset[_stateKey] = stateValue
const css = stateEntry.stateValues[stateValue]?.css
if (!css) {
return
}
Object.entries(css).forEach(([key, value]) => {
dom.style.setProperty(key, value)
})
})
}
})
})
}, [editor])
return null
}

View File

@@ -925,21 +925,21 @@ export { HeadingFeature, type HeadingFeatureProps } from './features/heading/ser
export { HorizontalRuleFeature } from './features/horizontalRule/server/index.js'
export { IndentFeature } from './features/indent/server/index.js'
export { AutoLinkNode } from './features/link/nodes/AutoLinkNode.js'
export { LinkNode } from './features/link/nodes/LinkNode.js'
export type { LinkFields } from './features/link/nodes/types.js'
export { LinkFeature, type LinkFeatureServerProps } from './features/link/server/index.js'
export { ChecklistFeature } from './features/lists/checklist/server/index.js'
export { OrderedListFeature } from './features/lists/orderedList/server/index.js'
export { UnorderedListFeature } from './features/lists/unorderedList/server/index.js'
export type {
SlateNode,
SlateNodeConverter,
} from './features/migrations/slateToLexical/converter/types.js'
export { ParagraphFeature } from './features/paragraph/server/index.js'
export {
RelationshipFeature,
type RelationshipFeatureProps,
@@ -949,6 +949,9 @@ export {
type RelationshipData,
RelationshipServerNode,
} from './features/relationship/server/nodes/RelationshipNode.js'
export { defaultColors } from './features/textState/defaultColors.js'
export { TextStateFeature } from './features/textState/feature.server.js'
export { FixedToolbarFeature } from './features/toolbars/fixed/server/index.js'
export { InlineToolbarFeature } from './features/toolbars/inline/server/index.js'

View File

@@ -0,0 +1,30 @@
import type { StyleObject } from '../../../../features/textState/feature.server.js'
function kebabToCamelCase(str: string): string {
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
}
export const TextStateIcon: React.FC<{
css?: StyleObject
}> = ({ css }) => {
const convertedCss = css
? Object.fromEntries(Object.entries(css).map(([key, value]) => [kebabToCamelCase(key), value]))
: {}
return (
<span
style={{
...convertedCss,
alignItems: 'center',
borderRadius: '4px',
display: 'flex',
fontSize: '16px',
height: '20px',
justifyContent: 'center',
width: '20px',
}}
>
A
</span>
)
}

View File

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

View File

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

View File

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

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