Compare commits
61 Commits
fix/unlock
...
ci/test-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5003baf0cd | ||
|
|
9962602646 | ||
|
|
179778223f | ||
|
|
1e708bdd12 | ||
|
|
36921bd62b | ||
|
|
3af0468062 | ||
|
|
8f6d2e79a1 | ||
|
|
54acdad190 | ||
|
|
312aa639b6 | ||
|
|
2163b0fdb5 | ||
|
|
9724067242 | ||
|
|
5cc0e74471 | ||
|
|
6939a835ca | ||
|
|
143b6e3b8e | ||
|
|
4ebe67324a | ||
|
|
bbfff30d41 | ||
|
|
ba30d7641f | ||
|
|
04b046847b | ||
|
|
62c0872bbb | ||
|
|
31e217967e | ||
|
|
8f203bbbe1 | ||
|
|
f0ea9185ef | ||
|
|
672dace969 | ||
|
|
e36ab6aa2a | ||
|
|
bacc0f002a | ||
|
|
f01cfbcc57 | ||
|
|
4f822a439b | ||
|
|
cc05937633 | ||
|
|
64b63f6833 | ||
|
|
5adb764b08 | ||
|
|
56dec13820 | ||
|
|
1d168318d0 | ||
|
|
f143d25728 | ||
|
|
7d2480aef9 | ||
|
|
c417e3a627 | ||
|
|
efce1549d0 | ||
|
|
d57a78616a | ||
|
|
a3fe60778c | ||
|
|
4ddf96502c | ||
|
|
562acb7492 | ||
|
|
bf4fa59026 | ||
|
|
fd1a4f689e | ||
|
|
a15c38f665 | ||
|
|
fa8a2f8d8d | ||
|
|
b9108b4306 | ||
|
|
6a3d58bb32 | ||
|
|
192964417d | ||
|
|
f03d450d8e | ||
|
|
398d48ab16 | ||
|
|
377454416a | ||
|
|
cd29978faf | ||
|
|
e1b30842fb | ||
|
|
927078c4db | ||
|
|
dda17f0c32 | ||
|
|
6d8aca5ab3 | ||
|
|
c828e336ee | ||
|
|
90d3c65008 | ||
|
|
e75d38ca82 | ||
|
|
79a7b4ad02 | ||
|
|
f7f5651004 | ||
|
|
45a7c8b764 |
2
.github/ISSUE_TEMPLATE/1.bug_report_v3.yml
vendored
2
.github/ISSUE_TEMPLATE/1.bug_report_v3.yml
vendored
@@ -57,7 +57,7 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environment Info
|
||||
description: Paste output from `pnpm payload info` _or_ Payload, Node.js, and Next.js versions.
|
||||
description: Paste output from `pnpm payload info` _or_ Payload, Node.js, and Next.js versions. Please avoid using "latest"—specific version numbers help us accurately diagnose and resolve issues.
|
||||
render: text
|
||||
placeholder: |
|
||||
Payload:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/2.design_issue.yml
vendored
2
.github/ISSUE_TEMPLATE/2.design_issue.yml
vendored
@@ -20,7 +20,7 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Environment Info
|
||||
description: Paste output from `pnpm payload info` _or_ Payload, Node.js, and Next.js versions.
|
||||
description: Paste output from `pnpm payload info` _or_ Payload, Node.js, and Next.js versions. Please avoid using "latest"—specific version numbers help us accurately diagnose and resolve issues.
|
||||
render: text
|
||||
placeholder: |
|
||||
Payload:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "es5",
|
||||
"lib": ["es2020.string"],
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
|
||||
2
.github/actions/triage/tsconfig.json
vendored
2
.github/actions/triage/tsconfig.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"target": "es5",
|
||||
"lib": ["es2020.string"],
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
|
||||
11
.github/workflows/main.yml
vendored
11
.github/workflows/main.yml
vendored
@@ -275,6 +275,7 @@ jobs:
|
||||
- admin__e2e__general
|
||||
- admin__e2e__list-view
|
||||
- admin__e2e__document-view
|
||||
- admin-bar
|
||||
- admin-root
|
||||
- auth
|
||||
- auth-basic
|
||||
@@ -313,6 +314,7 @@ jobs:
|
||||
- i18n
|
||||
- plugin-cloud-storage
|
||||
- plugin-form-builder
|
||||
- plugin-import-export
|
||||
- plugin-nested-docs
|
||||
- plugin-seo
|
||||
- versions
|
||||
@@ -417,6 +419,7 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: payloadtests
|
||||
MONGODB_VERSION: 6.0
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -456,8 +459,14 @@ jobs:
|
||||
echo "POSTGRES_URL=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:5432/$POSTGRES_DB" >> $GITHUB_ENV
|
||||
if: matrix.database == 'postgres'
|
||||
|
||||
# Avoid dockerhub rate-limiting
|
||||
- name: Cache Docker images
|
||||
uses: ScribeMD/docker-cache@0.5.0
|
||||
with:
|
||||
key: docker-${{ runner.os }}-mongo-${{ env.MONGODB_VERSION }}
|
||||
|
||||
- name: Start MongoDB
|
||||
uses: supercharge/mongodb-github-action@1.11.0
|
||||
uses: supercharge/mongodb-github-action@1.12.0
|
||||
with:
|
||||
mongodb-version: 6.0
|
||||
if: matrix.database == 'mongodb'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: release-canary
|
||||
name: publish-prerelease
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -11,7 +11,7 @@ env:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: release-canary-${{ github.ref_name }}-${{ github.sha }}
|
||||
name: publish-prerelease-${{ github.ref_name }}-${{ github.sha }}
|
||||
permissions:
|
||||
id-token: write
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -27,8 +27,19 @@ jobs:
|
||||
run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: Canary release script
|
||||
run: pnpm release:canary
|
||||
|
||||
- name: Determine release type
|
||||
id: determine_release_type
|
||||
# Use 'canary' for main branch, 'internal' for others
|
||||
run: |
|
||||
if [[ ${{ github.ref_name }} == "main" ]]; then
|
||||
echo "::set-output name=release_type::canary"
|
||||
else
|
||||
echo "::set-output name=release_type::internal"
|
||||
fi
|
||||
|
||||
- name: Release
|
||||
run: pnpm publish-prerelease --tag ${{ steps.determine_release_type.outputs.release_type }}
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -306,6 +306,8 @@ $RECYCLE.BIN/
|
||||
/build
|
||||
.swc
|
||||
app/(payload)/admin/importMap.js
|
||||
test/admin-bar/app/(payload)/admin/importMap.js
|
||||
/test/admin-bar/app/(payload)/admin/importMap.js
|
||||
test/live-preview/app/(payload)/admin/importMap.js
|
||||
/test/live-preview/app/(payload)/admin/importMap.js
|
||||
test/admin-root/app/(payload)/admin/importMap.js
|
||||
@@ -318,3 +320,6 @@ test/databaseAdapter.js
|
||||
/media-with-relation-preview
|
||||
/media-without-relation-preview
|
||||
/media-without-cache-tags
|
||||
test/.localstack
|
||||
test/google-cloud-storage
|
||||
test/azurestoragedata/
|
||||
|
||||
3
.idea/payload.iml
generated
3
.idea/payload.iml
generated
@@ -80,8 +80,9 @@
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/drizzle/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/db-sqlite/.turbo" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/db-sqlite/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/packages/plugin-import-export/dist" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
</module>
|
||||
@@ -1,4 +1,5 @@
|
||||
<a href="https://payloadcms.com"><img width="100%" src="https://l4wlsi8vxy8hre4v.public.blob.vercel-storage.com/github-banner-new-logo.jpg" alt="Payload headless CMS Admin panel built with React" /></a>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
|
||||
@@ -57,9 +57,9 @@ export const Posts: CollectionConfig = {
|
||||
|
||||
The following options are available:
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
|
||||
| Option | Description |
|
||||
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `admin` | The configuration options for the Admin Panel. [More details](#admin-options). |
|
||||
| `access` | Provide Access Control functions to define exactly who should be able to do what with Documents in this Collection. [More details](../access-control/collections). |
|
||||
| `auth` | Specify options if you would like this Collection to feature authentication. [More details](../authentication/overview). |
|
||||
| `custom` | Extension point for adding custom data (e.g. for plugins) |
|
||||
@@ -67,17 +67,18 @@ The following options are available:
|
||||
| `defaultSort` | Pass a top-level field to sort by default in the Collection List View. Prefix the name of the field with a minus symbol ("-") to sort in descending order. Multiple fields can be specified by using a string array. |
|
||||
| `dbName` | Custom table or Collection name depending on the Database Adapter. Auto-generated from slug if not defined. |
|
||||
| `endpoints` | Add custom routes to the REST API. Set to `false` to disable routes. [More details](../rest-api/overview#custom-endpoints). |
|
||||
| `fields` * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
|
||||
| `fields` * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [More details](../fields/overview). |
|
||||
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
|
||||
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
|
||||
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||
| `lockDocuments` | Enables or disables document locking. By default, document locking is enabled. Set to an object to configure, or set to `false` to disable locking. [More details](../admin/locked-documents). |
|
||||
| `slug` * | Unique, URL-friendly string that will act as an identifier for this Collection. |
|
||||
| `slug` * | Unique, URL-friendly string that will act as an identifier for this Collection. |
|
||||
| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
|
||||
| `typescript` | An object with property `interface` as the text used in schema generation. Auto-generated from slug if not defined. |
|
||||
| `upload` | Specify options if you would like this Collection to support file uploads. For more, consult the [Uploads](../upload/overview) documentation. |
|
||||
| `versions` | Set to true to enable default options, or configure with object properties. [More details](../versions/overview#collection-config). |
|
||||
| `defaultPopulate` | Specify which fields to select when this Collection is populated from another document. [More Details](../queries/select#defaultpopulate-collection-config-property). |
|
||||
| `indexes` | Define compound indexes for this collection. This can be used to either speed up querying/sorting by 2 or more fields at the same time or to ensure uniqueness between several fields. |
|
||||
|
||||
_* An asterisk denotes that a property is required._
|
||||
|
||||
|
||||
@@ -119,10 +119,32 @@ For details on how to build Custom Components, see [Building Custom Components](
|
||||
|
||||
### Import Map
|
||||
|
||||
In order for Payload to make use of [Component Paths](#component-paths), an "Import Map" is automatically generated at `app/(payload)/admin/importMap.js`. This file contains every Custom Component in your config, keyed to their respective paths. When Payload needs to lookup a component, it uses this file to find the correct import.
|
||||
In order for Payload to make use of [Component Paths](#component-paths), an "Import Map" is automatically generated at either `src/app/(payload)/admin/importMap.js` or `app/(payload)/admin/importMap.js`. This file contains every Custom Component in your config, keyed to their respective paths. When Payload needs to lookup a component, it uses this file to find the correct import.
|
||||
|
||||
The Import Map is automatically regenerated at startup and whenever Hot Module Replacement (HMR) runs, or you can run `payload generate:importmap` to manually regenerate it.
|
||||
|
||||
#### Overriding Import Map Location
|
||||
|
||||
Using the `config.admin.importMap.importMapFile` property, you can override the location of the import map. This is useful if you want to place the import map in a different location, or if you want to use a custom file name.
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import path from 'path'
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
|
||||
const config = buildConfig({
|
||||
// ...
|
||||
admin: {
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname, 'src'),
|
||||
importMapFile: path.resolve(dirname, 'app', '(payload)', 'custom-import-map.js'), // highlight-line
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
#### Custom Imports
|
||||
|
||||
If needed, custom items can be appended onto the Import Map. This is mostly only relevant for plugin authors who need to add a custom import that is not referenced in a known location.
|
||||
@@ -146,7 +168,7 @@ export default buildConfig({
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Building Custom Components
|
||||
|
||||
@@ -84,6 +84,7 @@ The Blocks Field inherits all of the default options from the base [Field Admin
|
||||
| **`group`** | Text or localization object used to group this Block in the Blocks Drawer. |
|
||||
| **`initCollapsed`** | Set the initial collapsed state |
|
||||
| **`isSortable`** | Disable order sorting by setting this value to `false` |
|
||||
| **`disableBlockName`** | Hide the blockName field by setting this value to `true` |
|
||||
|
||||
#### Customizing the way your block is rendered in Lexical
|
||||
|
||||
@@ -165,7 +166,7 @@ The `blockType` is saved as the slug of the block that has been selected.
|
||||
|
||||
**`blockName`**
|
||||
|
||||
The Admin Panel provides each block with a `blockName` field which optionally allows editors to label their blocks for better editability and readability.
|
||||
The Admin Panel provides each block with a `blockName` field which optionally allows editors to label their blocks for better editability and readability. This can be visually hidden via `admin.disableBlockName`.
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -502,7 +502,7 @@ export const MyCollectionConfig: CollectionConfig = {
|
||||
|
||||
All Description Functions receive the following arguments:
|
||||
|
||||
| Argument | Description |
|
||||
| Argument | Description |
|
||||
| --- | --- |
|
||||
| **`t`** | The `t` function used to internationalize the Admin Panel. [More details](../configuration/i18n) |
|
||||
|
||||
@@ -512,11 +512,21 @@ All Description Functions receive the following arguments:
|
||||
|
||||
### Conditional Logic
|
||||
|
||||
You can show and hide fields based on what other fields are doing by utilizing conditional logic on a field by field basis. The `condition` property on a field's admin config accepts a function which takes three arguments:
|
||||
You can show and hide fields based on what other fields are doing by utilizing conditional logic on a field by field basis. The `condition` property on a field's admin config accepts a function which takes the following arguments:
|
||||
|
||||
- `data` - the entire document's data that is currently being edited
|
||||
- `siblingData` - only the fields that are direct siblings to the field with the condition
|
||||
- `{ user }` - the final argument is an object containing the currently authenticated user
|
||||
| Argument | Description |
|
||||
| --- | --- |
|
||||
| **`data`** | The entire document's data that is currently being edited. |
|
||||
| **`siblingData`** | Only the fields that are direct siblings to the field with the condition. |
|
||||
| **`ctx`** | An object containing additional information about the field’s location and user. |
|
||||
|
||||
The `ctx` object:
|
||||
|
||||
| Property | Description |
|
||||
| --- | --- |
|
||||
| **`blockData`** | The nearest parent block's data. If the field is not inside a block, this will be `undefined`. |
|
||||
| **`path`** | The full path to the field in the schema, including array indexes. Useful for dynamic lookups. |
|
||||
| **`user`** | The currently authenticated user object. |
|
||||
|
||||
The `condition` function should return a boolean that will control if the field should be displayed or not.
|
||||
|
||||
@@ -535,7 +545,7 @@ The `condition` function should return a boolean that will control if the field
|
||||
type: 'text',
|
||||
admin: {
|
||||
// highlight-start
|
||||
condition: (data, siblingData, { user }) => {
|
||||
condition: (data, siblingData, { blockData, path, user }) => {
|
||||
if (data.enableGreeting) {
|
||||
return true
|
||||
} else {
|
||||
|
||||
@@ -239,16 +239,13 @@ export const useLivePreview = <T extends any>(props: {
|
||||
|
||||
## Example
|
||||
|
||||
For a working demonstration of this, check out the official [Live Preview Example](https://github.com/payloadcms/payload/tree/main/examples/live-preview). There you will find examples of various front-end frameworks and how to integrate each one of them, including:
|
||||
|
||||
- [Next.js App Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-app)
|
||||
- [Next.js Pages Router](https://github.com/payloadcms/payload/tree/main/examples/live-preview/next-pages)
|
||||
For a working demonstration of this, check out the official [Live Preview Example](https://github.com/payloadcms/payload/tree/main/examples/live-preview). There you will find an example of a fully integrated Next.js App Router front-end that runs on the same server as Payload.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
#### Relationships and/or uploads are not populating
|
||||
|
||||
If you are using relationships or uploads in your front-end application, and your front-end application runs on a different domain than your Payload server, you may need to configure [CORS](../configuration/overview) to allow requests to be made between the two domains. This includes sites that are running on a different port or subdomain. Similarly, if you are protecting resources behind user authentication, you may also need to configure [CSRF](../authentication/overview#csrf-protection) to allow cookies to be sent between the two domains. For example:
|
||||
If you are using relationships or uploads in your front-end application, and your front-end application runs on a different domain than your Payload server, you may need to configure [CORS](../configuration/overview#cors) to allow requests to be made between the two domains. This includes sites that are running on a different port or subdomain. Similarly, if you are protecting resources behind user authentication, you may also need to configure [CSRF](../authentication/cookies#csrf-prevention) to allow cookies to be sent between the two domains. For example:
|
||||
|
||||
```ts
|
||||
// payload.config.ts
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
title: Lexical Converters
|
||||
label: Converters
|
||||
order: 20
|
||||
desc: Conversion between lexical, markdown and html
|
||||
keywords: lexical, rich text, editor, headless cms, convert, html, mdx, markdown, md, conversion, export
|
||||
desc: Conversion between lexical, markdown, jsx and html
|
||||
keywords: lexical, rich text, editor, headless cms, convert, html, mdx, markdown, md, conversion, export, jsx
|
||||
---
|
||||
|
||||
Lexical saves data in JSON - this is great for storage and flexibility and allows you to easily to convert it to other formats like JSX, HTML or Markdown.
|
||||
@@ -74,20 +74,28 @@ To convert Lexical Blocks or Inline Blocks to JSX, pass the converter for your b
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
|
||||
import type { DefaultNodeTypes, SerializedBlockNode } from '@payloadcms/richtext-lexical'
|
||||
import type { MyInlineBlock, MyNumberBlock, MyTextBlock } from '@/payload-types'
|
||||
import type {
|
||||
DefaultNodeTypes,
|
||||
SerializedBlockNode,
|
||||
SerializedInlineBlockNode,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||
|
||||
import { type JSXConvertersFunction, RichText } from '@payloadcms/richtext-lexical/react'
|
||||
import React from 'react'
|
||||
|
||||
// Extend the default node types with your custom blocks for full type safety
|
||||
type NodeTypes = DefaultNodeTypes | SerializedBlockNode<MyInlineBlock | MyTextBlock>
|
||||
type NodeTypes =
|
||||
| DefaultNodeTypes
|
||||
| SerializedBlockNode<MyNumberBlock | MyTextBlock>
|
||||
| SerializedInlineBlockNode<MyInlineBlock>
|
||||
|
||||
const jsxConverters: JSXConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
blocks: {
|
||||
// Each key should match your block's slug
|
||||
myNumberBlock: ({ node }) => <div>{node.fields.number}</div>,
|
||||
myTextBlock: ({ node }) => <div style={{ backgroundColor: 'red' }}>{node.fields.text}</div>,
|
||||
},
|
||||
inlineBlocks: {
|
||||
@@ -155,19 +163,156 @@ export const MyComponent: React.FC<{
|
||||
|
||||
If you don't have a React-based frontend, or if you need to send the content to a third-party service, you can convert lexical to HTML. There are two ways to do this:
|
||||
|
||||
1. **Outputting HTML from the Collection:** Create a new field in your collection to convert saved JSON content to HTML. Payload generates and outputs the HTML for use in your frontend.
|
||||
2. **Generating HTML on any server** Convert JSON to HTML on-demand on the server.
|
||||
|
||||
In both cases, the conversion needs to happen on a server, as the HTML converter will automatically fetch data for nodes that require it (e.g. uploads and internal links). The editor comes with built-in HTML serializers, simplifying the process of converting JSON to HTML.
|
||||
1. **Generating HTML in your frontend** Convert JSON to HTML on-demand wherever you need it (Recommended).
|
||||
2. **Outputting HTML from the Collection:** Create a new field in your collection to convert saved JSON content to HTML. Payload generates and outputs the HTML for use in your frontend. This is not recommended, as this approach adds additional overhead to the Payload API and may not work with live preview.
|
||||
|
||||
### Generating HTML in your frontend
|
||||
|
||||
If you wish to convert JSON to HTML ad-hoc, use the `convertLexicalToHTML` function exported from `@payloadcms/richtext-lexical/html`:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||
import { convertLexicalToHTML } from '@payloadcms/richtext-lexical/html'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
|
||||
const html = convertLexicalToHTML({ data })
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
```
|
||||
|
||||
### Generating HTML in your frontend with dynamic population
|
||||
|
||||
The default `convertLexicalToHTML` function does not populate data for nodes like uploads or links - it expects you to pass in the fully populated data. If you want the converter to dynamically populate those nodes as they are encountered, you have to use the async version of the converter, imported from `@payloadcms/richtext-lexical/html-async`, and pass in the `populate` function:
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||
|
||||
import { getRestPopulateFn } from '@payloadcms/richtext-lexical/client'
|
||||
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
|
||||
const [html, setHTML] = useState<null | string>(null)
|
||||
useEffect(() => {
|
||||
async function convert() {
|
||||
const html = await convertLexicalToHTMLAsync({
|
||||
data,
|
||||
populate: getRestPopulateFn({
|
||||
apiURL: `http://localhost:3000/api`,
|
||||
}),
|
||||
})
|
||||
setHTML(html)
|
||||
}
|
||||
|
||||
void convert()
|
||||
}, [data])
|
||||
|
||||
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
```
|
||||
|
||||
Do note that using the REST populate function will result in each node sending a separate request to the REST API, which may be slow for a large amount of nodes. On the server, you can use the payload populate function, which will be more efficient:
|
||||
|
||||
```tsx
|
||||
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||
|
||||
import { getPayloadPopulateFn } from '@payloadcms/richtext-lexical'
|
||||
import { convertLexicalToHTMLAsync } from '@payloadcms/richtext-lexical/html-async'
|
||||
import { getPayload } from 'payload'
|
||||
import React from 'react'
|
||||
|
||||
import config from '../../config.js'
|
||||
|
||||
export const MyRSCComponent = async ({ data }: { data: SerializedEditorState }) => {
|
||||
const payload = await getPayload({
|
||||
config,
|
||||
})
|
||||
|
||||
const html = await convertLexicalToHTMLAsync({
|
||||
data,
|
||||
populate: await getPayloadPopulateFn({
|
||||
currentDepth: 0,
|
||||
depth: 1,
|
||||
payload,
|
||||
}),
|
||||
})
|
||||
|
||||
return html && <div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
```
|
||||
|
||||
### Converting Lexical Blocks
|
||||
|
||||
```tsx
|
||||
'use client'
|
||||
|
||||
import type { MyInlineBlock, MyTextBlock } from '@/payload-types'
|
||||
import type {
|
||||
DefaultNodeTypes,
|
||||
SerializedBlockNode,
|
||||
SerializedInlineBlockNode,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||
|
||||
import {
|
||||
convertLexicalToHTML,
|
||||
type HTMLConvertersFunction,
|
||||
} from '@payloadcms/richtext-lexical/html'
|
||||
import React from 'react'
|
||||
|
||||
type NodeTypes =
|
||||
| DefaultNodeTypes
|
||||
| SerializedBlockNode<MyTextBlock>
|
||||
| SerializedInlineBlockNode<MyInlineBlock>
|
||||
|
||||
const htmlConverters: HTMLConvertersFunction<NodeTypes> = ({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
blocks: {
|
||||
// Each key should match your block's slug
|
||||
myTextBlock: ({ node, providedCSSString }) =>
|
||||
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
|
||||
},
|
||||
inlineBlocks: {
|
||||
// Each key should match your inline block's slug
|
||||
myInlineBlock: ({ node, providedStyleTag }) =>
|
||||
`<span${providedStyleTag}>${node.fields.text}</span$>`,
|
||||
},
|
||||
})
|
||||
|
||||
export const MyComponent = ({ data }: { data: SerializedEditorState }) => {
|
||||
const html = convertLexicalToHTML({
|
||||
converters: htmlConverters,
|
||||
data,
|
||||
})
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: html }} />
|
||||
}
|
||||
```
|
||||
|
||||
### Outputting HTML from the Collection
|
||||
|
||||
To add HTML generation directly within the collection, follow the example below:
|
||||
|
||||
```ts
|
||||
import type { HTMLConvertersFunction } from '@payloadcms/richtext-lexical/html'
|
||||
import type { MyTextBlock } from '@/payload-types.js'
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
import { HTMLConverterFeature, lexicalEditor, lexicalHTML } from '@payloadcms/richtext-lexical'
|
||||
import {
|
||||
BlocksFeature,
|
||||
type DefaultNodeTypes,
|
||||
lexicalEditor,
|
||||
lexicalHTMLField,
|
||||
type SerializedBlockNode,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
@@ -175,71 +320,53 @@ const Pages: CollectionConfig = {
|
||||
{
|
||||
name: 'nameOfYourRichTextField',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor(),
|
||||
},
|
||||
lexicalHTMLField({
|
||||
htmlFieldName: 'nameOfYourRichTextField_html',
|
||||
lexicalFieldName: 'nameOfYourRichTextField',
|
||||
}),
|
||||
{
|
||||
name: 'customRichText',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
// The HTMLConverter Feature is the feature which manages the HTML serializers.
|
||||
// If you do not pass any arguments to it, it will use the default serializers.
|
||||
HTMLConverterFeature({}),
|
||||
BlocksFeature({
|
||||
blocks: [
|
||||
{
|
||||
interfaceName: 'MyTextBlock',
|
||||
slug: 'myTextBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
lexicalHTML('nameOfYourRichTextField', { name: 'nameOfYourRichTextField_html' }),
|
||||
lexicalHTMLField({
|
||||
htmlFieldName: 'customRichText_html',
|
||||
lexicalFieldName: 'customRichText',
|
||||
// can pass in additional converters or override default ones
|
||||
converters: (({ defaultConverters }) => ({
|
||||
...defaultConverters,
|
||||
blocks: {
|
||||
myTextBlock: ({ node, providedCSSString }) =>
|
||||
`<div style="background-color: red;${providedCSSString}">${node.fields.text}</div>`,
|
||||
},
|
||||
})) as HTMLConvertersFunction<DefaultNodeTypes | SerializedBlockNode<MyTextBlock>>,
|
||||
}),
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
The `lexicalHTML()` function creates a new field that automatically converts the referenced lexical richText field into HTML through an afterRead hook.
|
||||
|
||||
### Generating HTML anywhere on the server
|
||||
|
||||
If you wish to convert JSON to HTML ad-hoc, use the `convertLexicalToHTML` function:
|
||||
|
||||
```ts
|
||||
import { consolidateHTMLConverters, convertLexicalToHTML } from '@payloadcms/richtext-lexical'
|
||||
|
||||
|
||||
await convertLexicalToHTML({
|
||||
converters: consolidateHTMLConverters({ editorConfig }),
|
||||
data: editorData,
|
||||
payload, // if you have Payload but no req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes)
|
||||
req, // if you have req available, pass it in here to enable server-only functionality (e.g. proper conversion of upload nodes). No need to pass in Payload if req is passed in.
|
||||
})
|
||||
```
|
||||
This method employs `convertLexicalToHTML` from `@payloadcms/richtext-lexical`, which converts the serialized editor state into HTML.
|
||||
|
||||
Because every `Feature` is able to provide html converters, and because the `htmlFeature` can modify those or provide their own, we need to consolidate them with the default html Converters using the `consolidateHTMLConverters` function.
|
||||
|
||||
#### Example: Generating HTML within an afterRead hook
|
||||
|
||||
```ts
|
||||
import type { FieldHook } from 'payload'
|
||||
|
||||
import {
|
||||
HTMLConverterFeature,
|
||||
consolidateHTMLConverters,
|
||||
convertLexicalToHTML,
|
||||
defaultEditorConfig,
|
||||
defaultEditorFeatures,
|
||||
sanitizeServerEditorConfig,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
const hook: FieldHook = async ({ req, siblingData }) => {
|
||||
const editorConfig = defaultEditorConfig
|
||||
|
||||
editorConfig.features = [...defaultEditorFeatures, HTMLConverterFeature({})]
|
||||
|
||||
const sanitizedEditorConfig = await sanitizeServerEditorConfig(editorConfig, req.payload.config)
|
||||
|
||||
const html = await convertLexicalToHTML({
|
||||
converters: consolidateHTMLConverters({ editorConfig: sanitizedEditorConfig }),
|
||||
data: siblingData.lexicalSimple,
|
||||
req,
|
||||
})
|
||||
return html
|
||||
}
|
||||
```
|
||||
|
||||
### CSS
|
||||
|
||||
Payload's lexical HTML converter does not generate CSS for you, but it does add classes to the generated HTML. You can use these classes to style the HTML in your frontend.
|
||||
@@ -253,141 +380,85 @@ Here is some "base" CSS you can use to ensure that nested lists render correctly
|
||||
}
|
||||
```
|
||||
|
||||
### Creating your own HTML Converter
|
||||
|
||||
HTML Converters are typed as `HTMLConverter`, which contains the node type it should handle, and a function that accepts the serialized node from the lexical editor, and outputs the HTML string. Here's the HTML Converter of the Upload node as an example:
|
||||
|
||||
```ts
|
||||
import type { HTMLConverter } from '@payloadcms/richtext-lexical'
|
||||
|
||||
const UploadHTMLConverter: HTMLConverter<SerializedUploadNode> = {
|
||||
converter: async ({ node, req }) => {
|
||||
const uploadDocument: {
|
||||
value?: any
|
||||
} = {}
|
||||
if(req) {
|
||||
await populate({
|
||||
id,
|
||||
collectionSlug: node.relationTo,
|
||||
currentDepth: 0,
|
||||
data: uploadDocument,
|
||||
depth: 1,
|
||||
draft: false,
|
||||
key: 'value',
|
||||
overrideAccess: false,
|
||||
req,
|
||||
showHiddenFields: false,
|
||||
})
|
||||
}
|
||||
|
||||
const url = (req?.payload?.config?.serverURL || '') + uploadDocument?.value?.url
|
||||
|
||||
if (!(uploadDocument?.value?.mimeType as string)?.startsWith('image')) {
|
||||
// Only images can be serialized as HTML
|
||||
return ``
|
||||
}
|
||||
|
||||
return `<img src="${url}" alt="${uploadDocument?.value?.filename}" width="${uploadDocument?.value?.width}" height="${uploadDocument?.value?.height}"/>`
|
||||
},
|
||||
nodeTypes: [UploadNode.getType()], // This is the type of the lexical node that this converter can handle. Instead of hardcoding 'upload' we can get the node type directly from the UploadNode, since it's static.
|
||||
}
|
||||
```
|
||||
|
||||
As you can see, we have access to all the information saved in the node (for the Upload node, this is `value`and `relationTo`) and we can use that to generate the HTML.
|
||||
|
||||
The `convertLexicalToHTML` is part of `@payloadcms/richtext-lexical` automatically handles traversing the editor state and calling the correct converter for each node.
|
||||
|
||||
### Embedding the HTML Converter in your Feature
|
||||
|
||||
You can embed your HTML Converter directly within your custom `ServerFeature`, allowing it to be handled automatically by the `consolidateHTMLConverters` function. Here is an example:
|
||||
|
||||
```ts
|
||||
import { createNode } from '@payloadcms/richtext-lexical'
|
||||
import type { FeatureProviderProviderServer } from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const UploadFeature: FeatureProviderProviderServer<
|
||||
UploadFeatureProps,
|
||||
UploadFeaturePropsClient
|
||||
> = (props) => {
|
||||
/*...*/
|
||||
return {
|
||||
feature: () => {
|
||||
return {
|
||||
nodes: [
|
||||
createNode({
|
||||
converters: {
|
||||
html: yourHTMLConverter, // <= This is where you define your HTML Converter
|
||||
},
|
||||
node: UploadNode,
|
||||
//...
|
||||
}),
|
||||
],
|
||||
ClientComponent: UploadFeatureClientComponent,
|
||||
clientFeatureProps: clientProps,
|
||||
serverFeatureProps: props,
|
||||
/*...*/
|
||||
}
|
||||
},
|
||||
key: 'upload',
|
||||
serverFeatureProps: props,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Headless Editor
|
||||
|
||||
Lexical provides a seamless way to perform conversions between various other formats:
|
||||
|
||||
- HTML to Lexical (or, importing HTML into the lexical editor)
|
||||
- Markdown to Lexical (or, importing Markdown into the lexical editor)
|
||||
- HTML to Lexical
|
||||
- Markdown to Lexical
|
||||
- Lexical to Markdown
|
||||
|
||||
A headless editor can perform such conversions outside of the main editor instance. Follow this method to initiate a headless editor:
|
||||
|
||||
```ts
|
||||
import { createHeadlessEditor } from '@payloadcms/richtext-lexical/lexical/headless'
|
||||
import { getEnabledNodes, sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
|
||||
import { getEnabledNodes, editorConfigFactory } from '@payloadcms/richtext-lexical'
|
||||
|
||||
const yourEditorConfig // <= your editor config here
|
||||
const payloadConfig // <= your Payload Config here
|
||||
|
||||
const headlessEditor = createHeadlessEditor({
|
||||
nodes: getEnabledNodes({
|
||||
editorConfig: sanitizeServerEditorConfig(yourEditorConfig, payloadConfig),
|
||||
editorConfig: await editorConfigFactory.default({config: payloadConfig})
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
### Getting the editor config
|
||||
|
||||
As you can see, you need to provide an editor config in order to create a headless editor. This is because the editor config is used to determine which nodes & features are enabled, and which converters are used.
|
||||
You need to provide an editor config in order to create a headless editor. This is because the editor config is used to determine which nodes & features are enabled, and which converters are used.
|
||||
|
||||
To get the editor config, simply import the default editor config and adjust it - just like you did inside of the `editor: lexicalEditor({})` property:
|
||||
To get the editor config, import the `editorConfigFactory` factory - this factory provides a variety of ways to get the editor config, depending on your use case.
|
||||
|
||||
```ts
|
||||
import { defaultEditorConfig, defaultEditorFeatures } from '@payloadcms/richtext-lexical' // <= make sure this package is installed
|
||||
import type { SanitizedConfig } from 'payload'
|
||||
|
||||
const yourEditorConfig = defaultEditorConfig
|
||||
import {
|
||||
editorConfigFactory,
|
||||
FixedToolbarFeature,
|
||||
lexicalEditor,
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
// If you made changes to the features of the field's editor config, you should also make those changes here:
|
||||
yourEditorConfig.features = [
|
||||
...defaultEditorFeatures,
|
||||
// Add your custom features here
|
||||
]
|
||||
// Your config needs to be available in order to retrieve the default editor config
|
||||
const config: SanitizedConfig = {} as SanitizedConfig
|
||||
|
||||
// Version 1 - use the default editor config
|
||||
const yourEditorConfig = await editorConfigFactory.default({ config })
|
||||
|
||||
// Version 2 - if you have access to a lexical fields, you can extract the editor config from it
|
||||
const yourEditorConfig2 = editorConfigFactory.fromField({
|
||||
field: collectionConfig.fields[1],
|
||||
})
|
||||
|
||||
// Version 3 - create a new editor config - behaves just like instantiating a new `lexicalEditor`
|
||||
const yourEditorConfig3 = await editorConfigFactory.fromFeatures({
|
||||
config,
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures, FixedToolbarFeature()],
|
||||
})
|
||||
|
||||
// Version 4 - if you have instantiated a lexical editor and are accessing it outside a field (=> this is the unsanitized editor),
|
||||
// you can extract the editor config from it.
|
||||
// This is common if you define the editor in a re-usable module scope variable and pass it to the richText field.
|
||||
// This is the least efficient way to get the editor config, and not recommended. It is recommended to extract the `features` arg
|
||||
// into a separate variable and use `fromFeatures` instead.
|
||||
const editor = lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [...defaultFeatures, FixedToolbarFeature()],
|
||||
})
|
||||
|
||||
const yourEditorConfig4 = await editorConfigFactory.fromEditor({
|
||||
config,
|
||||
editor,
|
||||
})
|
||||
```
|
||||
|
||||
### Getting the editor config from an existing field
|
||||
### Example - Getting the editor config from an existing field
|
||||
|
||||
If you have access to the sanitized collection config, you can get access to the lexical sanitized editor config & features, as every lexical richText field returns it. Here is an example how you can get it from another field's afterRead hook:
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig, RichTextField } from 'payload'
|
||||
|
||||
import { editorConfigFactory, getEnabledNodes, lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import { createHeadlessEditor } from '@payloadcms/richtext-lexical/lexical/headless'
|
||||
import type { LexicalRichTextAdapter, SanitizedServerEditorConfig } from '@payloadcms/richtext-lexical'
|
||||
import {
|
||||
getEnabledNodes,
|
||||
lexicalEditor
|
||||
} from '@payloadcms/richtext-lexical'
|
||||
|
||||
export const MyCollection: CollectionConfig = {
|
||||
slug: 'slug',
|
||||
@@ -397,20 +468,18 @@ export const MyCollection: CollectionConfig = {
|
||||
type: 'text',
|
||||
hooks: {
|
||||
afterRead: [
|
||||
({ value, collection }) => {
|
||||
const otherRichTextField: RichTextField = collection.fields.find(
|
||||
({ siblingFields, value }) => {
|
||||
const field: RichTextField = siblingFields.find(
|
||||
(field) => 'name' in field && field.name === 'richText',
|
||||
) as RichTextField
|
||||
|
||||
const lexicalAdapter: LexicalRichTextAdapter =
|
||||
otherRichTextField.editor as LexicalRichTextAdapter
|
||||
|
||||
const sanitizedServerEditorConfig: SanitizedServerEditorConfig =
|
||||
lexicalAdapter.editorConfig
|
||||
const editorConfig = editorConfigFactory.fromField({
|
||||
field,
|
||||
})
|
||||
|
||||
const headlessEditor = createHeadlessEditor({
|
||||
nodes: getEnabledNodes({
|
||||
editorConfig: sanitizedServerEditorConfig,
|
||||
editorConfig,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -424,37 +493,43 @@ export const MyCollection: CollectionConfig = {
|
||||
{
|
||||
name: 'richText',
|
||||
type: 'richText',
|
||||
editor: lexicalEditor({
|
||||
features,
|
||||
}),
|
||||
}
|
||||
]
|
||||
editor: lexicalEditor(),
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## HTML => Lexical
|
||||
|
||||
Once you have your headless editor instance, you can use it to convert HTML to Lexical:
|
||||
If you have access to the Payload Config and the lexical editor config, you can convert HTML to the lexical editor state with the following:
|
||||
|
||||
```ts
|
||||
import { $generateNodesFromDOM } from '@payloadcms/richtext-lexical/lexical/html'
|
||||
import { $getRoot, $getSelection } from '@payloadcms/richtext-lexical/lexical'
|
||||
import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical'
|
||||
// Make sure you have jsdom and @types/jsdom installed
|
||||
import { JSDOM } from 'jsdom'
|
||||
|
||||
const html = convertHTMLToLexical({
|
||||
editorConfig: await editorConfigFactory.default({
|
||||
config, // <= make sure you have access to your Payload Config
|
||||
}),
|
||||
html: '<p>text</p>',
|
||||
JSDOM, // pass the JSDOM import. As it's a relatively large package, richtext-lexical does not include it by default.
|
||||
})
|
||||
```
|
||||
|
||||
## Markdown => Lexical
|
||||
|
||||
Convert markdown content to the Lexical editor format with the following:
|
||||
|
||||
```ts
|
||||
import { $convertFromMarkdownString, editorConfigFactory } from '@payloadcms/richtext-lexical'
|
||||
|
||||
const yourEditorConfig = await editorConfigFactory.default({ config })
|
||||
const markdown = `# Hello World`
|
||||
|
||||
headlessEditor.update(
|
||||
() => {
|
||||
// In a headless environment you can use a package such as JSDom to parse the HTML string.
|
||||
const dom = new JSDOM(htmlString)
|
||||
|
||||
// Once you have the DOM instance it's easy to generate LexicalNodes.
|
||||
const nodes = $generateNodesFromDOM(headlessEditor, dom.window.document)
|
||||
|
||||
// Select the root
|
||||
$getRoot().select()
|
||||
|
||||
// Insert them at a selection.
|
||||
const selection = $getSelection()
|
||||
selection.insertNodes(nodes)
|
||||
$convertFromMarkdownString(markdown, yourEditorConfig.features.markdownTransformers)
|
||||
},
|
||||
{ discrete: true },
|
||||
)
|
||||
@@ -474,27 +549,6 @@ This has been taken from the [lexical serialization & deserialization docs](http
|
||||
immediate reading of the updated state isn't necessary, you can omit the flag.
|
||||
</Banner>
|
||||
|
||||
## Markdown => Lexical
|
||||
|
||||
Convert markdown content to the Lexical editor format with the following:
|
||||
|
||||
```ts
|
||||
import { sanitizeServerEditorConfig, $convertFromMarkdownString } from '@payloadcms/richtext-lexical'
|
||||
|
||||
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig, payloadConfig) // <= your editor config & Payload Config here
|
||||
const markdown = `# Hello World`
|
||||
|
||||
headlessEditor.update(
|
||||
() => {
|
||||
$convertFromMarkdownString(markdown, yourSanitizedEditorConfig.features.markdownTransformers)
|
||||
},
|
||||
{ discrete: true },
|
||||
)
|
||||
|
||||
// Do this if you then want to get the editor JSON
|
||||
const editorJSON = headlessEditor.getEditorState().toJSON()
|
||||
```
|
||||
|
||||
## Lexical => Markdown
|
||||
|
||||
Export content from the Lexical editor into Markdown format using these steps:
|
||||
@@ -505,11 +559,12 @@ Export content from the Lexical editor into Markdown format using these steps:
|
||||
Here's the code for it:
|
||||
|
||||
```ts
|
||||
import { $convertToMarkdownString } from '@payloadcms/richtext-lexical/lexical/markdown'
|
||||
import { sanitizeServerEditorConfig } from '@payloadcms/richtext-lexical'
|
||||
import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical'
|
||||
|
||||
const yourSanitizedEditorConfig = sanitizeServerEditorConfig(yourEditorConfig, payloadConfig) // <= your editor config & Payload Config here
|
||||
import { editorConfigFactory } from '@payloadcms/richtext-lexical'
|
||||
import { $convertToMarkdownString } from '@payloadcms/richtext-lexical/lexical/markdown'
|
||||
|
||||
const yourEditorConfig = await editorConfigFactory.default({ config })
|
||||
const yourEditorState: SerializedEditorState // <= your current editor state here
|
||||
|
||||
// Import editor state into your headless editor
|
||||
@@ -518,7 +573,7 @@ try {
|
||||
() => {
|
||||
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
|
||||
},
|
||||
{ discrete: true }, // This should commit the editor state immediately
|
||||
{ discrete: true }, // This should commit the editor state immediately
|
||||
)
|
||||
} catch (e) {
|
||||
logger.error({ err: e }, 'ERROR parsing editor state')
|
||||
@@ -527,7 +582,7 @@ try {
|
||||
// Export to markdown
|
||||
let markdown: string
|
||||
headlessEditor.getEditorState().read(() => {
|
||||
markdown = $convertToMarkdownString(yourSanitizedEditorConfig?.features?.markdownTransformers)
|
||||
markdown = $convertToMarkdownString(yourEditorConfig?.features?.markdownTransformers)
|
||||
})
|
||||
```
|
||||
|
||||
@@ -548,13 +603,13 @@ const yourEditorState: SerializedEditorState // <= your current editor state her
|
||||
|
||||
// Import editor state into your headless editor
|
||||
try {
|
||||
headlessEditor.update(
|
||||
headlessEditor.update(
|
||||
() => {
|
||||
headlessEditor.setEditorState(headlessEditor.parseEditorState(yourEditorState))
|
||||
},
|
||||
{ discrete: true }, // This should commit the editor state immediately
|
||||
{ discrete: true }, // This should commit the editor state immediately
|
||||
)
|
||||
} catch (e) {
|
||||
} catch (e) {
|
||||
logger.error({ err: e }, 'ERROR parsing editor state')
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ Here's an overview of all the included features:
|
||||
| **`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 |
|
||||
| **`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 |
|
||||
|
||||
@@ -185,6 +185,8 @@ The [Admin Panel](../admin/overview) will also automatically display all availab
|
||||
|
||||
Behind the scenes, Payload relies on [`sharp`](https://sharp.pixelplumbing.com/api-resize#resize) to perform its image resizing. You can specify additional options for `sharp` to use while resizing your images.
|
||||
|
||||
Note that for image resizing to work, `sharp` must be specified in your [Payload Config](../configuration/overview). This is configured by default if you created your Payload project with `create-payload-app`. See `sharp` in [Config Options](../configuration/overview#config-options).
|
||||
|
||||
#### Accessing the resized images in hooks
|
||||
|
||||
All auto-resized images are exposed to be re-used in hooks and similar via an object that is bound to `req.payloadUploadSizes`.
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"graphql": "^16.9.0",
|
||||
"next": "^15.0.0",
|
||||
"payload": "latest",
|
||||
"payload-admin-bar": "^1.0.6",
|
||||
"payload-admin-bar": "^1.0.7",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
},
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
"next": "^15.1.0",
|
||||
"next-intl": "^3.23.2",
|
||||
"payload": "latest",
|
||||
"payload-admin-bar": "^1.0.6",
|
||||
"payload-admin-bar": "^1.0.7",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
||||
5057
examples/localization/pnpm-lock.yaml
generated
5057
examples/localization/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
||||
DATABASE_URI=mongodb://127.0.0.1/payload-example-multi-tenant
|
||||
POSTGRES_URL=postgres://127.0.0.1:5432/payload-example-multi-tenant
|
||||
PAYLOAD_SECRET=PAYLOAD_MULTI_TENANT_EXAMPLE_SECRET_KEY
|
||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
SEED_DB=true
|
||||
@@ -46,12 +46,12 @@ See the [Collections](https://payloadcms.com/docs/configuration/collections) doc
|
||||
|
||||
**Domain-based Tenant Setting**:
|
||||
|
||||
This example also supports domain-based tenant selection, where tenants can be associated with a specific domain. If a tenant is associated with a domain (e.g., `gold.test:3000`), when a user logs in from that domain, they will be automatically scoped to the matching tenant. This is accomplished through an optional `afterLogin` hook that sets a `payload-tenant` cookie based on the domain.
|
||||
This example also supports domain-based tenant selection, where tenants can be associated with a specific domain. If a tenant is associated with a domain (e.g., `gold.localhost:3000`), when a user logs in from that domain, they will be automatically scoped to the matching tenant. This is accomplished through an optional `afterLogin` hook that sets a `payload-tenant` cookie based on the domain.
|
||||
|
||||
For the domain portion of the example to function properly, you will need to add the following entries to your system's `/etc/hosts` file:
|
||||
|
||||
```
|
||||
127.0.0.1 gold.test silver.test bronze.test
|
||||
127.0.0.1 gold.localhost silver.localhost bronze.localhost
|
||||
```
|
||||
|
||||
- #### Pages
|
||||
|
||||
2
examples/multi-tenant/next-env.d.ts
vendored
2
examples/multi-tenant/next-env.d.ts
vendored
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@payloadcms/db-mongodb": "latest",
|
||||
"@payloadcms/db-postgres": "^3.25.0",
|
||||
"@payloadcms/next": "latest",
|
||||
"@payloadcms/plugin-multi-tenant": "latest",
|
||||
"@payloadcms/richtext-lexical": "latest",
|
||||
|
||||
939
examples/multi-tenant/pnpm-lock.yaml
generated
939
examples/multi-tenant/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -10,10 +10,10 @@ export default async ({ params: paramsPromise }: { params: Promise<{ slug: strin
|
||||
<p>When you visit a tenant by domain, the domain is used to determine the tenant.</p>
|
||||
<p>
|
||||
For example, visiting{' '}
|
||||
<a href="http://gold.test:3000/tenant-domains/login">
|
||||
http://gold.test:3000/tenant-domains/login
|
||||
<a href="http://gold.localhost:3000/tenant-domains/login">
|
||||
http://gold.localhost:3000/tenant-domains/login
|
||||
</a>{' '}
|
||||
will show the tenant with the domain "gold.test".
|
||||
will show the tenant with the domain "gold.localhost".
|
||||
</p>
|
||||
|
||||
<h2>Slugs</h2>
|
||||
|
||||
@@ -3,10 +3,7 @@ import { TenantSelector as TenantSelector_1d0591e3cf4f332c83a86da13a0de59a } fro
|
||||
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
|
||||
|
||||
export const importMap = {
|
||||
'@payloadcms/plugin-multi-tenant/client#TenantField':
|
||||
TenantField_1d0591e3cf4f332c83a86da13a0de59a,
|
||||
'@payloadcms/plugin-multi-tenant/client#TenantSelector':
|
||||
TenantSelector_1d0591e3cf4f332c83a86da13a0de59a,
|
||||
'@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider':
|
||||
TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62,
|
||||
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
|
||||
"@payloadcms/plugin-multi-tenant/client#TenantSelector": TenantSelector_1d0591e3cf4f332c83a86da13a0de59a,
|
||||
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FieldHook } from 'payload'
|
||||
import type { FieldHook, Where } from 'payload'
|
||||
|
||||
import { ValidationError } from 'payload'
|
||||
|
||||
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||
import { extractID } from '@/utilities/extractID'
|
||||
|
||||
export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, value }) => {
|
||||
// if value is unchanged, skip validation
|
||||
@@ -10,26 +11,30 @@ export const ensureUniqueSlug: FieldHook = async ({ data, originalDoc, req, valu
|
||||
return value
|
||||
}
|
||||
|
||||
const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant
|
||||
const currentTenantID =
|
||||
typeof originalDoc?.tenant === 'object' ? originalDoc.tenant.id : originalDoc?.tenant
|
||||
const constraints: Where[] = [
|
||||
{
|
||||
slug: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const incomingTenantID = extractID(data?.tenant)
|
||||
const currentTenantID = extractID(originalDoc?.tenant)
|
||||
const tenantIDToMatch = incomingTenantID || currentTenantID
|
||||
|
||||
if (tenantIDToMatch) {
|
||||
constraints.push({
|
||||
tenant: {
|
||||
equals: tenantIDToMatch,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const findDuplicatePages = await req.payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
tenant: {
|
||||
equals: tenantIDToMatch,
|
||||
},
|
||||
},
|
||||
{
|
||||
slug: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
],
|
||||
and: constraints,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { User } from '@/payload-types'
|
||||
import type { Access, Where } from 'payload'
|
||||
|
||||
import { parseCookies } from 'payload'
|
||||
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
|
||||
|
||||
import { isSuperAdmin } from '../../../access/isSuperAdmin'
|
||||
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||
import { isAccessingSelf } from './isAccessingSelf'
|
||||
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
|
||||
|
||||
export const readAccess: Access<User> = ({ req, id }) => {
|
||||
if (!req?.user) {
|
||||
@@ -16,9 +16,11 @@ export const readAccess: Access<User> = ({ req, id }) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const cookies = parseCookies(req.headers)
|
||||
const superAdmin = isSuperAdmin(req.user)
|
||||
const selectedTenant = cookies.get('payload-tenant')
|
||||
const selectedTenant = getTenantFromCookie(
|
||||
req.headers,
|
||||
getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }),
|
||||
)
|
||||
const adminTenantAccessIDs = getUserTenantIDs(req.user, 'tenant-admin')
|
||||
|
||||
if (selectedTenant) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { FieldHook } from 'payload'
|
||||
import type { FieldHook, Where } from 'payload'
|
||||
|
||||
import { ValidationError } from 'payload'
|
||||
|
||||
import { getUserTenantIDs } from '../../../utilities/getUserTenantIDs'
|
||||
import { extractID } from '@/utilities/extractID'
|
||||
import { getTenantFromCookie } from '@payloadcms/plugin-multi-tenant/utilities'
|
||||
import { getCollectionIDType } from '@/utilities/getCollectionIDType'
|
||||
|
||||
export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req, value }) => {
|
||||
// if value is unchanged, skip validation
|
||||
@@ -10,26 +13,31 @@ export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req,
|
||||
return value
|
||||
}
|
||||
|
||||
const incomingTenantID = typeof data?.tenant === 'object' ? data.tenant.id : data?.tenant
|
||||
const currentTenantID =
|
||||
typeof originalDoc?.tenant === 'object' ? originalDoc.tenant.id : originalDoc?.tenant
|
||||
const tenantIDToMatch = incomingTenantID || currentTenantID
|
||||
const constraints: Where[] = [
|
||||
{
|
||||
username: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const selectedTenant = getTenantFromCookie(
|
||||
req.headers,
|
||||
getCollectionIDType({ payload: req.payload, collectionSlug: 'tenants' }),
|
||||
)
|
||||
|
||||
if (selectedTenant) {
|
||||
constraints.push({
|
||||
'tenants.tenant': {
|
||||
equals: selectedTenant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const findDuplicateUsers = await req.payload.find({
|
||||
collection: 'users',
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
'tenants.tenant': {
|
||||
equals: tenantIDToMatch,
|
||||
},
|
||||
},
|
||||
{
|
||||
username: {
|
||||
equals: value,
|
||||
},
|
||||
},
|
||||
],
|
||||
and: constraints,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -39,7 +47,8 @@ export const ensureUniqueUsername: FieldHook = async ({ data, originalDoc, req,
|
||||
// provide a more specific error message
|
||||
if (req.user.roles?.includes('super-admin') || tenantIDs.length > 1) {
|
||||
const attemptedTenantChange = await req.payload.findByID({
|
||||
id: tenantIDToMatch,
|
||||
// @ts-ignore - selectedTenant will match DB ID type
|
||||
id: selectedTenant,
|
||||
collection: 'tenants',
|
||||
})
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export const setCookieBasedOnDomain: CollectionAfterLoginHook = async ({ req, us
|
||||
expires: getCookieExpiration({ seconds: 7200 }),
|
||||
path: '/',
|
||||
returnCookieAsObject: false,
|
||||
value: relatedOrg.docs[0].id,
|
||||
value: String(relatedOrg.docs[0].id),
|
||||
})
|
||||
|
||||
// Merge existing responseHeaders with the new Set-Cookie header
|
||||
|
||||
@@ -6,10 +6,65 @@
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported timezones in IANA format.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| 'Pacific/Midway'
|
||||
| 'Pacific/Niue'
|
||||
| 'Pacific/Honolulu'
|
||||
| 'Pacific/Rarotonga'
|
||||
| 'America/Anchorage'
|
||||
| 'Pacific/Gambier'
|
||||
| 'America/Los_Angeles'
|
||||
| 'America/Tijuana'
|
||||
| 'America/Denver'
|
||||
| 'America/Phoenix'
|
||||
| 'America/Chicago'
|
||||
| 'America/Guatemala'
|
||||
| 'America/New_York'
|
||||
| 'America/Bogota'
|
||||
| 'America/Caracas'
|
||||
| 'America/Santiago'
|
||||
| 'America/Buenos_Aires'
|
||||
| 'America/Sao_Paulo'
|
||||
| 'Atlantic/South_Georgia'
|
||||
| 'Atlantic/Azores'
|
||||
| 'Atlantic/Cape_Verde'
|
||||
| 'Europe/London'
|
||||
| 'Europe/Berlin'
|
||||
| 'Africa/Lagos'
|
||||
| 'Europe/Athens'
|
||||
| 'Africa/Cairo'
|
||||
| 'Europe/Moscow'
|
||||
| 'Asia/Riyadh'
|
||||
| 'Asia/Dubai'
|
||||
| 'Asia/Baku'
|
||||
| 'Asia/Karachi'
|
||||
| 'Asia/Tashkent'
|
||||
| 'Asia/Calcutta'
|
||||
| 'Asia/Dhaka'
|
||||
| 'Asia/Almaty'
|
||||
| 'Asia/Jakarta'
|
||||
| 'Asia/Bangkok'
|
||||
| 'Asia/Shanghai'
|
||||
| 'Asia/Singapore'
|
||||
| 'Asia/Tokyo'
|
||||
| 'Asia/Seoul'
|
||||
| 'Australia/Sydney'
|
||||
| 'Pacific/Guam'
|
||||
| 'Pacific/Noumea'
|
||||
| 'Pacific/Auckland'
|
||||
| 'Pacific/Fiji';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
pages: Page;
|
||||
users: User;
|
||||
@@ -28,7 +83,7 @@ export interface Config {
|
||||
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: string;
|
||||
defaultIDType: number;
|
||||
};
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
@@ -64,8 +119,8 @@ export interface UserAuthOperations {
|
||||
* via the `definition` "pages".
|
||||
*/
|
||||
export interface Page {
|
||||
id: string;
|
||||
tenant?: (string | null) | Tenant;
|
||||
id: number;
|
||||
tenant?: (number | null) | Tenant;
|
||||
title?: string | null;
|
||||
slug?: string | null;
|
||||
updatedAt: string;
|
||||
@@ -76,7 +131,7 @@ export interface Page {
|
||||
* via the `definition` "tenants".
|
||||
*/
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
id: number;
|
||||
name: string;
|
||||
/**
|
||||
* Used for domain-based tenant handling
|
||||
@@ -98,12 +153,12 @@ export interface Tenant {
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
id: number;
|
||||
roles?: ('super-admin' | 'user')[] | null;
|
||||
username?: string | null;
|
||||
tenants?:
|
||||
| {
|
||||
tenant: string | Tenant;
|
||||
tenant: number | Tenant;
|
||||
roles: ('tenant-admin' | 'tenant-viewer')[];
|
||||
id?: string | null;
|
||||
}[]
|
||||
@@ -124,24 +179,24 @@ export interface User {
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: string;
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: 'pages';
|
||||
value: string | Page;
|
||||
value: number | Page;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'tenants';
|
||||
value: string | Tenant;
|
||||
value: number | Tenant;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
@@ -151,10 +206,10 @@ export interface PayloadLockedDocument {
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: string;
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: 'users';
|
||||
value: string | User;
|
||||
value: number | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
@@ -174,7 +229,7 @@ export interface PayloadPreference {
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: string;
|
||||
id: number;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mongooseAdapter } from '@payloadcms/db-mongodb'
|
||||
import { postgresAdapter } from '@payloadcms/db-postgres'
|
||||
import { lexicalEditor } from '@payloadcms/richtext-lexical'
|
||||
import path from 'path'
|
||||
import { buildConfig } from 'payload'
|
||||
@@ -11,6 +12,7 @@ import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
|
||||
import { isSuperAdmin } from './access/isSuperAdmin'
|
||||
import type { Config } from './payload-types'
|
||||
import { getUserTenantIDs } from './utilities/getUserTenantIDs'
|
||||
import { seed } from './seed'
|
||||
|
||||
const filename = fileURLToPath(import.meta.url)
|
||||
const dirname = path.dirname(filename)
|
||||
@@ -21,9 +23,19 @@ export default buildConfig({
|
||||
user: 'users',
|
||||
},
|
||||
collections: [Pages, Users, Tenants],
|
||||
db: mongooseAdapter({
|
||||
url: process.env.DATABASE_URI as string,
|
||||
// db: mongooseAdapter({
|
||||
// url: process.env.DATABASE_URI as string,
|
||||
// }),
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString: process.env.POSTGRES_URL,
|
||||
},
|
||||
}),
|
||||
onInit: async (args) => {
|
||||
if (process.env.SEED_DB) {
|
||||
await seed(args)
|
||||
}
|
||||
},
|
||||
editor: lexicalEditor({}),
|
||||
graphQL: {
|
||||
schemaOutputFile: path.resolve(dirname, 'generated-schema.graphql'),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { MigrateUpArgs } from '@payloadcms/db-mongodb'
|
||||
import { Config } from 'payload'
|
||||
|
||||
export async function up({ payload }: MigrateUpArgs): Promise<void> {
|
||||
export const seed: NonNullable<Config['onInit']> = async (payload): Promise<void> => {
|
||||
const tenant1 = await payload.create({
|
||||
collection: 'tenants',
|
||||
data: {
|
||||
name: 'Tenant 1',
|
||||
slug: 'gold',
|
||||
domain: 'gold.test',
|
||||
domain: 'gold.localhost',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
|
||||
data: {
|
||||
name: 'Tenant 2',
|
||||
slug: 'silver',
|
||||
domain: 'silver.test',
|
||||
domain: 'silver.localhost',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function up({ payload }: MigrateUpArgs): Promise<void> {
|
||||
data: {
|
||||
name: 'Tenant 3',
|
||||
slug: 'bronze',
|
||||
domain: 'bronze.test',
|
||||
domain: 'bronze.localhost',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CollectionSlug, Payload } from 'payload'
|
||||
|
||||
type Args = {
|
||||
collectionSlug: CollectionSlug
|
||||
payload: Payload
|
||||
}
|
||||
export const getCollectionIDType = ({ collectionSlug, payload }: Args): 'number' | 'text' => {
|
||||
return payload.collections[collectionSlug]?.customIDType ?? payload.db.defaultIDType
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const config = withBundleAnalyzer(
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
experimental: {
|
||||
fullySpecified: true,
|
||||
serverActions: {
|
||||
bodySizeLimit: '5mb',
|
||||
},
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.25.0",
|
||||
"version": "3.27.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"bf": "pnpm run build:force",
|
||||
"build": "pnpm run build:core",
|
||||
"build:admin-bar": "turbo build --filter \"@payloadcms/admin-bar\"",
|
||||
"build:all": "turbo build",
|
||||
"build:app": "next build",
|
||||
"build:app:analyze": "cross-env ANALYZE=true next build",
|
||||
@@ -33,6 +34,7 @@
|
||||
"build:payload-cloud": "turbo build --filter \"@payloadcms/payload-cloud\"",
|
||||
"build:plugin-cloud-storage": "turbo build --filter \"@payloadcms/plugin-cloud-storage\"",
|
||||
"build:plugin-form-builder": "turbo build --filter \"@payloadcms/plugin-form-builder\"",
|
||||
"build:plugin-import-export": "turbo build --filter \"@payloadcms/plugin-import-export\"",
|
||||
"build:plugin-multi-tenant": "turbo build --filter \"@payloadcms/plugin-multi-tenant\"",
|
||||
"build:plugin-nested-docs": "turbo build --filter \"@payloadcms/plugin-nested-docs\"",
|
||||
"build:plugin-redirects": "turbo build --filter \"@payloadcms/plugin-redirects\"",
|
||||
@@ -79,10 +81,9 @@
|
||||
"prepare": "husky",
|
||||
"prepare-run-test-against-prod": "pnpm bf && rm -rf test/packed && rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
|
||||
"prepare-run-test-against-prod:ci": "rm -rf test/node_modules && rm -rf app && rm -f test/pnpm-lock.yaml && pnpm run script:pack --all --no-build --dest test/packed && pnpm runts test/setupProd.ts && cd test && pnpm i --ignore-workspace && cd ..",
|
||||
"publish-prerelease": "pnpm --filter releaser publish-prerelease",
|
||||
"reinstall": "pnpm clean:all && pnpm install",
|
||||
"release": "pnpm --filter releaser release --tag latest",
|
||||
"release:beta": "pnpm runts ./scripts/release.ts --bump prerelease --tag beta",
|
||||
"release:canary": "pnpm --filter releaser release:canary",
|
||||
"runts": "cross-env NODE_OPTIONS=--no-deprecation node --no-deprecation --import @swc-node/register/esm-register",
|
||||
"script:build-template-with-local-pkgs": "pnpm --filter scripts build-template-with-local-pkgs",
|
||||
"script:gen-templates": "pnpm --filter scripts gen-templates",
|
||||
|
||||
10
packages/admin-bar/.prettierignore
Normal file
10
packages/admin-bar/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
24
packages/admin-bar/.swcrc
Normal file
24
packages/admin-bar/.swcrc
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"sourceMaps": true,
|
||||
"jsc": {
|
||||
"target": "esnext",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"dts": true
|
||||
},
|
||||
"transform": {
|
||||
"react": {
|
||||
"runtime": "automatic",
|
||||
"pragmaFrag": "React.Fragment",
|
||||
"throwIfNamespace": true,
|
||||
"development": false,
|
||||
"useBuiltins": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"module": {
|
||||
"type": "es6"
|
||||
}
|
||||
}
|
||||
22
packages/admin-bar/LICENSE.md
Normal file
22
packages/admin-bar/LICENSE.md
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2018-2025 Payload CMS, Inc. <info@payloadcms.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of this software and associated documentation files (the
|
||||
'Software'), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish,
|
||||
distribute, sublicense, and/or sell copies of the Software, and to
|
||||
permit persons to whom the Software is furnished to do so, subject to
|
||||
the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be
|
||||
included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
119
packages/admin-bar/README.md
Normal file
119
packages/admin-bar/README.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Payload Admin Bar
|
||||
|
||||
An admin bar for React apps using [Payload](https://github.com/payloadcms/payload).
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
pnpm i @payloadcms/admin-bar
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```jsx
|
||||
import { PayloadAdminBar } from '@payloadcms/admin-bar'
|
||||
|
||||
export const App = () => {
|
||||
return <PayloadAdminBar cmsURL="https://cms.website.com" collection="pages" id="12345" />
|
||||
}
|
||||
```
|
||||
|
||||
Checks for authentication with Payload CMS by hitting the [`/me`](https://payloadcms.com/docs/authentication/operations#me) route. If authenticated, renders an admin bar with simple controls to do the following:
|
||||
|
||||
- Navigate to the admin dashboard
|
||||
- Navigate to the currently logged-in user's account
|
||||
- Edit the current collection
|
||||
- Create a new collection of the same type
|
||||
- Logout
|
||||
- Indicate and exit preview mode
|
||||
|
||||
The admin bar ships with very little style and is fully customizable.
|
||||
|
||||
### Dynamic props
|
||||
|
||||
With client-side routing, we need to update the admin bar with a new collection type and document id on each route change. This will depend on your app's specific setup, but here are a some common examples:
|
||||
|
||||
#### NextJS
|
||||
|
||||
For NextJS apps using dynamic-routes, use `getStaticProps`:
|
||||
|
||||
```ts
|
||||
export const getStaticProps = async ({ params: { slug } }) => {
|
||||
const props = {}
|
||||
|
||||
const pageReq = await fetch(
|
||||
`https://cms.website.com/api/pages?where[slug][equals]=${slug}&depth=1`,
|
||||
)
|
||||
const pageData = await pageReq.json()
|
||||
|
||||
if (pageReq.ok) {
|
||||
const { docs } = pageData
|
||||
const [doc] = docs
|
||||
|
||||
props = {
|
||||
...doc,
|
||||
collection: 'pages',
|
||||
collectionLabels: {
|
||||
singular: 'page',
|
||||
plural: 'pages',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return props
|
||||
}
|
||||
```
|
||||
|
||||
Now your app can forward these props onto the admin bar. Something like this:
|
||||
|
||||
```ts
|
||||
import { PayloadAdminBar } from '@payloadcms/admin-bar';
|
||||
|
||||
export const App = (appProps) => {
|
||||
const {
|
||||
pageProps: {
|
||||
collection,
|
||||
collectionLabels,
|
||||
id
|
||||
}
|
||||
} = appProps;
|
||||
|
||||
return (
|
||||
<PayloadAdminBar
|
||||
{...{
|
||||
cmsURL: 'https://cms.website.com',
|
||||
collection,
|
||||
collectionLabels,
|
||||
id
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Property | Type | Required | Default | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------------------------------------------ | -------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| cmsURL | `string` | true | `http://localhost:8000` | `serverURL` as defined in your [Payload config](https://payloadcms.com/docs/configuration/overview#options) |
|
||||
| adminPath | `string` | false | /admin | `routes` as defined in your [Payload config](https://payloadcms.com/docs/configuration/overview#options) |
|
||||
| apiPath | `string` | false | /api | `routes` as defined in your [Payload config](https://payloadcms.com/docs/configuration/overview#options) |
|
||||
| authCollectionSlug | `string` | false | 'users' | Slug of your [auth collection](https://payloadcms.com/docs/configuration/collections) |
|
||||
| collectionSlug | `string` | true | undefined | Slug of your [collection](https://payloadcms.com/docs/configuration/collections) |
|
||||
| collectionLabels | `{ singular?: string, plural?: string }` | false | undefined | Labels of your [collection](https://payloadcms.com/docs/configuration/collections) |
|
||||
| id | `string` | true | undefined | id of the document |
|
||||
| logo | `ReactElement` | false | undefined | Custom logo |
|
||||
| classNames | `{ logo?: string, user?: string, controls?: string, create?: string, logout?: string, edit?: string, preview?: string }` | false | undefined | Custom class names, one for each rendered element |
|
||||
| logoProps | `{[key: string]?: unknown}` | false | undefined | Custom props |
|
||||
| userProps | `{[key: string]?: unknown}` | false | undefined | Custom props |
|
||||
| divProps | `{[key: string]?: unknown}` | false | undefined | Custom props |
|
||||
| createProps | `{[key: string]?: unknown}` | false | undefined | Custom props |
|
||||
| logoutProps | `{[key: string]?: unknown}` | false | undefined | Custom props |
|
||||
| editProps | `{[key: string]?: unknown}` | false | undefined | Custom props |
|
||||
| previewProps | `{[key: string]?: unknown}` | false | undefined | Custom props |
|
||||
| style | `CSSProperties` | false | undefined | Custom inline style |
|
||||
| unstyled | `boolean` | false | undefined | If true, renders no inline style |
|
||||
| onAuthChange | `(user: PayloadMeUser) => void` | false | undefined | Fired on each auth change |
|
||||
| devMode | `boolean` | false | undefined | If true, fakes authentication (useful when dealing with [SameSite cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie/SameSite)) |
|
||||
| preview | `boolean` | false | undefined | If true, renders an exit button with your `onPreviewExit` handler) |
|
||||
| onPreviewExit | `function` | false | undefined | Callback for the preview button `onClick` event) |
|
||||
18
packages/admin-bar/eslint.config.js
Normal file
18
packages/admin-bar/eslint.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { rootEslintConfig, rootParserOptions } from '../../eslint.config.js'
|
||||
|
||||
/** @typedef {import('eslint').Linter.Config} Config */
|
||||
|
||||
/** @type {Config[]} */
|
||||
export const index = [
|
||||
...rootEslintConfig,
|
||||
{
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
...rootParserOptions,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default index
|
||||
65
packages/admin-bar/package.json
Normal file
65
packages/admin-bar/package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@payloadcms/admin-bar",
|
||||
"version": "3.27.0",
|
||||
"description": "An admin bar for React apps using Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/payloadcms/payload.git",
|
||||
"directory": "packages/admin-bar"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Payload <dev@payloadcms.com> (https://payloadcms.com)",
|
||||
"maintainers": [
|
||||
{
|
||||
"name": "Payload",
|
||||
"email": "info@payloadcms.com",
|
||||
"url": "https://payloadcms.com"
|
||||
}
|
||||
],
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm copyfiles && pnpm build:types && pnpm build:swc",
|
||||
"build:swc": "swc ./src -d ./dist --config-file .swcrc --strip-leading-paths",
|
||||
"build:types": "tsc --emitDeclarationOnly --outDir dist",
|
||||
"clean": "rimraf -g {dist,*.tsbuildinfo}",
|
||||
"copyfiles": "copyfiles -u 1 \"src/**/*.{html,css,scss,ttf,woff,woff2,eot,svg,jpg,png,json}\" dist/",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"prepublishOnly": "pnpm clean && pnpm turbo build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"registry": "https://registry.npmjs.org/",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
}
|
||||
349
packages/admin-bar/src/AdminBar.tsx
Normal file
349
packages/admin-bar/src/AdminBar.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
'use client'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
const dummyUser = {
|
||||
id: '12345',
|
||||
email: 'dev@email.com',
|
||||
}
|
||||
|
||||
import type { PayloadAdminBarProps, PayloadMeUser } from './types.js'
|
||||
|
||||
export const PayloadAdminBar: React.FC<PayloadAdminBarProps> = (props) => {
|
||||
const {
|
||||
id: docID,
|
||||
adminPath = '/admin',
|
||||
apiPath = '/api',
|
||||
authCollectionSlug = 'users',
|
||||
className,
|
||||
classNames,
|
||||
cmsURL = 'http://localhost:8000',
|
||||
collectionLabels,
|
||||
collectionSlug,
|
||||
createProps,
|
||||
devMode,
|
||||
divProps,
|
||||
editProps,
|
||||
logo,
|
||||
logoProps,
|
||||
logoutProps,
|
||||
onAuthChange,
|
||||
onPreviewExit,
|
||||
preview,
|
||||
previewProps,
|
||||
style,
|
||||
unstyled,
|
||||
userProps,
|
||||
} = props
|
||||
|
||||
const [user, setUser] = useState<PayloadMeUser>()
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMe = async () => {
|
||||
try {
|
||||
const meRequest = await fetch(`${cmsURL}${apiPath}/${authCollectionSlug}/me`, {
|
||||
credentials: 'include',
|
||||
method: 'get',
|
||||
})
|
||||
const meResponse = await meRequest.json()
|
||||
const { user } = meResponse
|
||||
|
||||
if (user) {
|
||||
setUser(user)
|
||||
} else {
|
||||
if (devMode !== true) {
|
||||
setUser(null)
|
||||
} else {
|
||||
setUser(dummyUser)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
if (devMode === true) {
|
||||
setUser(dummyUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void fetchMe()
|
||||
}, [cmsURL, adminPath, apiPath, devMode])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof onAuthChange === 'function') {
|
||||
onAuthChange(user)
|
||||
}
|
||||
}, [user, onAuthChange])
|
||||
|
||||
if (user) {
|
||||
const { id: userID, email } = user
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
id="payload-admin-bar"
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#222',
|
||||
boxSizing: 'border-box',
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
fontFamily:
|
||||
'-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif',
|
||||
fontSize: 'small',
|
||||
left: 0,
|
||||
minWidth: '0',
|
||||
padding: '0.5rem',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
zIndex: 99999,
|
||||
}
|
||||
: {}),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<a
|
||||
className={classNames?.logo}
|
||||
href={`${cmsURL}${adminPath}`}
|
||||
{...logoProps}
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
alignItems: 'center',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
height: '20px',
|
||||
marginRight: '10px',
|
||||
textDecoration: 'none',
|
||||
...(logoProps?.style
|
||||
? {
|
||||
...logoProps.style,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{logo || 'Payload CMS'}
|
||||
</a>
|
||||
<a
|
||||
className={classNames?.user}
|
||||
href={`${cmsURL}${adminPath}/collections/${authCollectionSlug}/${userID}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...userProps}
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
color: 'inherit',
|
||||
display: 'block',
|
||||
marginRight: '10px',
|
||||
minWidth: '50px',
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
...(userProps?.style
|
||||
? {
|
||||
...userProps.style,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{email || 'Profile'}
|
||||
</span>
|
||||
</a>
|
||||
<div
|
||||
className={classNames?.controls}
|
||||
{...divProps}
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
alignItems: 'center',
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
justifyContent: 'flex-end',
|
||||
marginRight: '10px',
|
||||
...(divProps?.style
|
||||
? {
|
||||
...divProps.style,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{collectionSlug && docID && (
|
||||
<a
|
||||
className={classNames?.edit}
|
||||
href={`${cmsURL}${adminPath}/collections/${collectionSlug}/${docID}`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...editProps}
|
||||
style={{
|
||||
display: 'block',
|
||||
...(unstyled !== true
|
||||
? {
|
||||
color: 'inherit',
|
||||
flexShrink: 1,
|
||||
marginRight: '10px',
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
...(editProps?.style
|
||||
? {
|
||||
...editProps.style,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{`Edit ${collectionLabels?.singular || 'page'}`}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{collectionSlug && (
|
||||
<a
|
||||
className={classNames?.create}
|
||||
href={`${cmsURL}${adminPath}/collections/${collectionSlug}/create`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...createProps}
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
color: 'inherit',
|
||||
display: 'block',
|
||||
flexShrink: 1,
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
...(createProps?.style
|
||||
? {
|
||||
...createProps.style,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{`New ${collectionLabels?.singular || 'page'}`}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
{preview && (
|
||||
<button
|
||||
className={classNames?.preview}
|
||||
onClick={onPreviewExit}
|
||||
{...previewProps}
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
marginLeft: '10px',
|
||||
padding: 0,
|
||||
...(previewProps?.style
|
||||
? {
|
||||
...previewProps.style,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Exit preview mode
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
className={classNames?.logout}
|
||||
href={`${cmsURL}${adminPath}/logout`}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...logoutProps}
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
color: 'inherit',
|
||||
display: 'block',
|
||||
flexShrink: 1,
|
||||
overflow: 'hidden',
|
||||
textDecoration: 'none',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
...(logoutProps?.style
|
||||
? {
|
||||
...logoutProps.style,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
...(unstyled !== true
|
||||
? {
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
2
packages/admin-bar/src/index.ts
Normal file
2
packages/admin-bar/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { PayloadAdminBar } from './AdminBar.js'
|
||||
export type { PayloadAdminBarProps, PayloadMeUser } from './types.js'
|
||||
67
packages/admin-bar/src/types.ts
Normal file
67
packages/admin-bar/src/types.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { CSSProperties, ReactElement } from 'react'
|
||||
|
||||
export type PayloadMeUser =
|
||||
| {
|
||||
email: string
|
||||
id: string
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
|
||||
export type PayloadAdminBarProps = {
|
||||
adminPath?: string
|
||||
apiPath?: string
|
||||
authCollectionSlug?: string
|
||||
className?: string
|
||||
classNames?: {
|
||||
controls?: string
|
||||
create?: string
|
||||
edit?: string
|
||||
logo?: string
|
||||
logout?: string
|
||||
preview?: string
|
||||
user?: string
|
||||
}
|
||||
cmsURL?: string
|
||||
collectionLabels?: {
|
||||
plural?: string
|
||||
singular?: string
|
||||
}
|
||||
collectionSlug?: string
|
||||
createProps?: {
|
||||
[key: string]: unknown
|
||||
style?: CSSProperties
|
||||
}
|
||||
devMode?: boolean
|
||||
divProps?: {
|
||||
[key: string]: unknown
|
||||
style?: CSSProperties
|
||||
}
|
||||
editProps?: {
|
||||
[key: string]: unknown
|
||||
style?: CSSProperties
|
||||
}
|
||||
id?: string
|
||||
logo?: ReactElement
|
||||
logoProps?: {
|
||||
[key: string]: unknown
|
||||
style?: CSSProperties
|
||||
}
|
||||
logoutProps?: {
|
||||
[key: string]: unknown
|
||||
style?: CSSProperties
|
||||
}
|
||||
onAuthChange?: (user: PayloadMeUser) => void
|
||||
onPreviewExit?: () => void
|
||||
preview?: boolean
|
||||
previewProps?: {
|
||||
[key: string]: unknown
|
||||
style?: CSSProperties
|
||||
}
|
||||
style?: CSSProperties
|
||||
unstyled?: boolean
|
||||
userProps?: {
|
||||
[key: string]: unknown
|
||||
style?: CSSProperties
|
||||
}
|
||||
}
|
||||
9
packages/admin-bar/tsconfig.json
Normal file
9
packages/admin-bar/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
/* TODO: remove the following lines */
|
||||
"strict": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
},
|
||||
"references": [{ "path": "../payload" }]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-payload-app",
|
||||
"version": "3.25.0",
|
||||
"version": "3.27.0",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-mongodb",
|
||||
"version": "3.25.0",
|
||||
"version": "3.27.0",
|
||||
"description": "The officially supported MongoDB database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
@@ -53,6 +53,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@types/mongoose-aggregate-paginate-v2": "1.0.12",
|
||||
"@types/prompts": "^2.4.5",
|
||||
"@types/uuid": "10.0.0",
|
||||
"mongodb": "6.12.0",
|
||||
"mongodb-memory-server": "^10",
|
||||
"payload": "workspace:*"
|
||||
|
||||
@@ -70,9 +70,15 @@ export const connect: Connect = async function connect(
|
||||
await this.migrate({ migrations: this.prodMigrations })
|
||||
}
|
||||
} catch (err) {
|
||||
let msg = `Error: cannot connect to MongoDB.`
|
||||
|
||||
if (typeof err === 'object' && err && 'message' in err && typeof err.message === 'string') {
|
||||
msg = `${msg} Details: ${err.message}`
|
||||
}
|
||||
|
||||
this.payload.logger.error({
|
||||
err,
|
||||
msg: `Error: cannot connect to MongoDB. Details: ${err.message}`,
|
||||
msg,
|
||||
})
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -6,13 +6,15 @@ import { flattenWhereToOperators } from 'payload'
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
|
||||
export const count: Count = async function count(
|
||||
this: MongooseAdapter,
|
||||
{ collection, locale, req, where },
|
||||
{ collection: collectionSlug, locale, req, where = {} },
|
||||
) {
|
||||
const Model = this.collections[collection]
|
||||
const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug })
|
||||
|
||||
const options: CountOptions = {
|
||||
session: await getSession(this, req),
|
||||
}
|
||||
@@ -26,8 +28,8 @@ export const count: Count = async function count(
|
||||
|
||||
const query = await buildQuery({
|
||||
adapter: this,
|
||||
collectionSlug: collection,
|
||||
fields: this.payload.collections[collection].config.flattenedFields,
|
||||
collectionSlug,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
locale,
|
||||
where,
|
||||
})
|
||||
|
||||
@@ -6,13 +6,15 @@ import { buildVersionGlobalFields, flattenWhereToOperators } from 'payload'
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { getGlobal } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
|
||||
export const countGlobalVersions: CountGlobalVersions = async function countGlobalVersions(
|
||||
this: MongooseAdapter,
|
||||
{ global, locale, req, where },
|
||||
{ global: globalSlug, locale, req, where = {} },
|
||||
) {
|
||||
const Model = this.versions[global]
|
||||
const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug, versions: true })
|
||||
|
||||
const options: CountOptions = {
|
||||
session: await getSession(this, req),
|
||||
}
|
||||
@@ -26,11 +28,7 @@ export const countGlobalVersions: CountGlobalVersions = async function countGlob
|
||||
|
||||
const query = await buildQuery({
|
||||
adapter: this,
|
||||
fields: buildVersionGlobalFields(
|
||||
this.payload.config,
|
||||
this.payload.globals.config.find((each) => each.slug === global),
|
||||
true,
|
||||
),
|
||||
fields: buildVersionGlobalFields(this.payload.config, globalConfig, true),
|
||||
locale,
|
||||
where,
|
||||
})
|
||||
|
||||
@@ -6,13 +6,19 @@ import { buildVersionCollectionFields, flattenWhereToOperators } from 'payload'
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
|
||||
export const countVersions: CountVersions = async function countVersions(
|
||||
this: MongooseAdapter,
|
||||
{ collection, locale, req, where },
|
||||
{ collection: collectionSlug, locale, req, where = {} },
|
||||
) {
|
||||
const Model = this.versions[collection]
|
||||
const { collectionConfig, Model } = getCollection({
|
||||
adapter: this,
|
||||
collectionSlug,
|
||||
versions: true,
|
||||
})
|
||||
|
||||
const options: CountOptions = {
|
||||
session: await getSession(this, req),
|
||||
}
|
||||
@@ -26,11 +32,7 @@ export const countVersions: CountVersions = async function countVersions(
|
||||
|
||||
const query = await buildQuery({
|
||||
adapter: this,
|
||||
fields: buildVersionCollectionFields(
|
||||
this.payload.config,
|
||||
this.payload.collections[collection].config,
|
||||
true,
|
||||
),
|
||||
fields: buildVersionCollectionFields(this.payload.config, collectionConfig, true),
|
||||
locale,
|
||||
where,
|
||||
})
|
||||
|
||||
@@ -3,15 +3,17 @@ import type { Create } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { handleError } from './utilities/handleError.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const create: Create = async function create(
|
||||
this: MongooseAdapter,
|
||||
{ collection, data, req, returning },
|
||||
{ collection: collectionSlug, data, req, returning },
|
||||
) {
|
||||
const Model = this.collections[collection]
|
||||
const { collectionConfig, customIDType, Model } = getCollection({ adapter: this, collectionSlug })
|
||||
|
||||
const options: CreateOptions = {
|
||||
session: await getSession(this, req),
|
||||
}
|
||||
@@ -21,18 +23,18 @@ export const create: Create = async function create(
|
||||
transform({
|
||||
adapter: this,
|
||||
data,
|
||||
fields: this.payload.collections[collection].config.fields,
|
||||
fields: collectionConfig.fields,
|
||||
operation: 'write',
|
||||
})
|
||||
|
||||
if (this.payload.collections[collection].customIDType) {
|
||||
if (customIDType) {
|
||||
data._id = data.id
|
||||
}
|
||||
|
||||
try {
|
||||
;[doc] = await Model.create([data], options)
|
||||
} catch (error) {
|
||||
handleError({ collection, error, req })
|
||||
handleError({ collection: collectionSlug, error, req })
|
||||
}
|
||||
if (returning === false) {
|
||||
return null
|
||||
@@ -43,7 +45,7 @@ export const create: Create = async function create(
|
||||
transform({
|
||||
adapter: this,
|
||||
data: doc,
|
||||
fields: this.payload.collections[collection].config.fields,
|
||||
fields: collectionConfig.fields,
|
||||
operation: 'read',
|
||||
})
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import type { CreateOptions } from 'mongoose'
|
||||
import type { CreateGlobal } from 'payload'
|
||||
|
||||
import { type CreateGlobal } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { getGlobal } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const createGlobal: CreateGlobal = async function createGlobal(
|
||||
this: MongooseAdapter,
|
||||
{ slug, data, req, returning },
|
||||
{ slug: globalSlug, data, req, returning },
|
||||
) {
|
||||
const Model = this.globals
|
||||
const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug })
|
||||
|
||||
transform({
|
||||
adapter: this,
|
||||
data,
|
||||
fields: this.payload.config.globals.find((globalConfig) => globalConfig.slug === slug).fields,
|
||||
globalSlug: slug,
|
||||
fields: globalConfig.fields,
|
||||
globalSlug,
|
||||
operation: 'write',
|
||||
})
|
||||
|
||||
@@ -34,7 +36,7 @@ export const createGlobal: CreateGlobal = async function createGlobal(
|
||||
transform({
|
||||
adapter: this,
|
||||
data: result,
|
||||
fields: this.payload.config.globals.find((globalConfig) => globalConfig.slug === slug).fields,
|
||||
fields: globalConfig.fields,
|
||||
operation: 'read',
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { CreateOptions } from 'mongoose'
|
||||
|
||||
import { buildVersionGlobalFields, type CreateGlobalVersion } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { getGlobal } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
@@ -22,8 +21,9 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
|
||||
versionData,
|
||||
},
|
||||
) {
|
||||
const VersionModel = this.versions[globalSlug]
|
||||
const options: CreateOptions = {
|
||||
const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug, versions: true })
|
||||
|
||||
const options = {
|
||||
session: await getSession(this, req),
|
||||
}
|
||||
|
||||
@@ -38,10 +38,7 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
|
||||
version: versionData,
|
||||
}
|
||||
|
||||
const fields = buildVersionGlobalFields(
|
||||
this.payload.config,
|
||||
this.payload.config.globals.find((global) => global.slug === globalSlug),
|
||||
)
|
||||
const fields = buildVersionGlobalFields(this.payload.config, globalConfig)
|
||||
|
||||
transform({
|
||||
adapter: this,
|
||||
@@ -50,9 +47,9 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
|
||||
operation: 'write',
|
||||
})
|
||||
|
||||
let [doc] = await VersionModel.create([data], options, req)
|
||||
let [doc] = await Model.create([data], options, req)
|
||||
|
||||
await VersionModel.updateMany(
|
||||
await Model.updateMany(
|
||||
{
|
||||
$and: [
|
||||
{
|
||||
|
||||
@@ -42,8 +42,9 @@ export const createMigration: CreateMigration = async function createMigration({
|
||||
const migrationFileContent = migrationTemplate(predefinedMigration)
|
||||
|
||||
const [yyymmdd, hhmmss] = new Date().toISOString().split('T')
|
||||
const formattedDate = yyymmdd.replace(/\D/g, '')
|
||||
const formattedTime = hhmmss.split('.')[0].replace(/\D/g, '')
|
||||
|
||||
const formattedDate = yyymmdd!.replace(/\D/g, '')
|
||||
const formattedTime = hhmmss!.split('.')[0]!.replace(/\D/g, '')
|
||||
|
||||
const timestamp = `${formattedDate}_${formattedTime}`
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { CreateOptions } from 'mongoose'
|
||||
|
||||
import { buildVersionCollectionFields, type CreateVersion } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
@@ -22,8 +21,13 @@ export const createVersion: CreateVersion = async function createVersion(
|
||||
versionData,
|
||||
},
|
||||
) {
|
||||
const VersionModel = this.versions[collectionSlug]
|
||||
const options: CreateOptions = {
|
||||
const { collectionConfig, Model } = getCollection({
|
||||
adapter: this,
|
||||
collectionSlug,
|
||||
versions: true,
|
||||
})
|
||||
|
||||
const options = {
|
||||
session: await getSession(this, req),
|
||||
}
|
||||
|
||||
@@ -38,10 +42,7 @@ export const createVersion: CreateVersion = async function createVersion(
|
||||
version: versionData,
|
||||
}
|
||||
|
||||
const fields = buildVersionCollectionFields(
|
||||
this.payload.config,
|
||||
this.payload.collections[collectionSlug].config,
|
||||
)
|
||||
const fields = buildVersionCollectionFields(this.payload.config, collectionConfig)
|
||||
|
||||
transform({
|
||||
adapter: this,
|
||||
@@ -50,7 +51,7 @@ export const createVersion: CreateVersion = async function createVersion(
|
||||
operation: 'write',
|
||||
})
|
||||
|
||||
let [doc] = await VersionModel.create([data], options, req)
|
||||
let [doc] = await Model.create([data], options, req)
|
||||
|
||||
const parentQuery = {
|
||||
$or: [
|
||||
@@ -62,7 +63,7 @@ export const createVersion: CreateVersion = async function createVersion(
|
||||
],
|
||||
}
|
||||
|
||||
await VersionModel.updateMany(
|
||||
await Model.updateMany(
|
||||
{
|
||||
$and: [
|
||||
{
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import type { DeleteOptions } from 'mongodb'
|
||||
import type { DeleteMany } from 'payload'
|
||||
|
||||
import { type DeleteMany } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
|
||||
export const deleteMany: DeleteMany = async function deleteMany(
|
||||
this: MongooseAdapter,
|
||||
{ collection, req, where },
|
||||
{ collection: collectionSlug, req, where },
|
||||
) {
|
||||
const Model = this.collections[collection]
|
||||
const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug })
|
||||
|
||||
const options: DeleteOptions = {
|
||||
session: await getSession(this, req),
|
||||
}
|
||||
|
||||
const query = await buildQuery({
|
||||
adapter: this,
|
||||
collectionSlug: collection,
|
||||
fields: this.payload.collections[collection].config.flattenedFields,
|
||||
collectionSlug,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
where,
|
||||
})
|
||||
|
||||
|
||||
@@ -5,18 +5,20 @@ import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const deleteOne: DeleteOne = async function deleteOne(
|
||||
this: MongooseAdapter,
|
||||
{ collection, req, returning, select, where },
|
||||
{ collection: collectionSlug, req, returning, select, where },
|
||||
) {
|
||||
const Model = this.collections[collection]
|
||||
const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug })
|
||||
|
||||
const options: MongooseUpdateQueryOptions = {
|
||||
projection: buildProjectionFromSelect({
|
||||
adapter: this,
|
||||
fields: this.payload.collections[collection].config.flattenedFields,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
select,
|
||||
}),
|
||||
session: await getSession(this, req),
|
||||
@@ -24,8 +26,8 @@ export const deleteOne: DeleteOne = async function deleteOne(
|
||||
|
||||
const query = await buildQuery({
|
||||
adapter: this,
|
||||
collectionSlug: collection,
|
||||
fields: this.payload.collections[collection].config.flattenedFields,
|
||||
collectionSlug,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
where,
|
||||
})
|
||||
|
||||
@@ -43,7 +45,7 @@ export const deleteOne: DeleteOne = async function deleteOne(
|
||||
transform({
|
||||
adapter: this,
|
||||
data: doc,
|
||||
fields: this.payload.collections[collection].config.fields,
|
||||
fields: collectionConfig.fields,
|
||||
operation: 'read',
|
||||
})
|
||||
|
||||
|
||||
@@ -3,26 +3,27 @@ import { buildVersionCollectionFields, type DeleteVersions } from 'payload'
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
|
||||
export const deleteVersions: DeleteVersions = async function deleteVersions(
|
||||
this: MongooseAdapter,
|
||||
{ collection, locale, req, where },
|
||||
{ collection: collectionSlug, locale, req, where },
|
||||
) {
|
||||
const VersionsModel = this.versions[collection]
|
||||
const { collectionConfig, Model } = getCollection({
|
||||
adapter: this,
|
||||
collectionSlug,
|
||||
versions: true,
|
||||
})
|
||||
|
||||
const session = await getSession(this, req)
|
||||
|
||||
const query = await buildQuery({
|
||||
adapter: this,
|
||||
fields: buildVersionCollectionFields(
|
||||
this.payload.config,
|
||||
this.payload.collections[collection].config,
|
||||
true,
|
||||
),
|
||||
fields: buildVersionCollectionFields(this.payload.config, collectionConfig, true),
|
||||
locale,
|
||||
where,
|
||||
})
|
||||
|
||||
await VersionsModel.deleteMany(query, { session })
|
||||
await Model.deleteMany(query, { session })
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ import { buildSortParam } from './queries/buildSortParam.js'
|
||||
import { aggregatePaginate } from './utilities/aggregatePaginate.js'
|
||||
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const find: Find = async function find(
|
||||
this: MongooseAdapter,
|
||||
{
|
||||
collection,
|
||||
collection: collectionSlug,
|
||||
joins = {},
|
||||
limit = 0,
|
||||
locale,
|
||||
@@ -26,11 +27,10 @@ export const find: Find = async function find(
|
||||
req,
|
||||
select,
|
||||
sort: sortArg,
|
||||
where,
|
||||
where = {},
|
||||
},
|
||||
) {
|
||||
const Model = this.collections[collection]
|
||||
const collectionConfig = this.payload.collections[collection].config
|
||||
const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug })
|
||||
|
||||
const session = await getSession(this, req)
|
||||
|
||||
@@ -54,8 +54,8 @@ export const find: Find = async function find(
|
||||
|
||||
const query = await buildQuery({
|
||||
adapter: this,
|
||||
collectionSlug: collection,
|
||||
fields: this.payload.collections[collection].config.flattenedFields,
|
||||
collectionSlug,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
locale,
|
||||
where,
|
||||
})
|
||||
@@ -109,7 +109,8 @@ export const find: Find = async function find(
|
||||
if (limit >= 0) {
|
||||
paginationOptions.limit = limit
|
||||
// limit must also be set here, it's ignored when pagination is false
|
||||
paginationOptions.options.limit = limit
|
||||
|
||||
paginationOptions.options!.limit = limit
|
||||
|
||||
// Disable pagination if limit is 0
|
||||
if (limit === 0) {
|
||||
@@ -121,7 +122,7 @@ export const find: Find = async function find(
|
||||
|
||||
const aggregate = await buildJoinAggregation({
|
||||
adapter: this,
|
||||
collection,
|
||||
collection: collectionSlug,
|
||||
collectionConfig,
|
||||
joins,
|
||||
locale,
|
||||
@@ -139,7 +140,7 @@ export const find: Find = async function find(
|
||||
pagination: paginationOptions.pagination,
|
||||
projection: paginationOptions.projection,
|
||||
query,
|
||||
session: paginationOptions.options?.session,
|
||||
session: paginationOptions.options?.session ?? undefined,
|
||||
sort: paginationOptions.sort as object,
|
||||
useEstimatedCount: paginationOptions.useEstimatedCount,
|
||||
})
|
||||
@@ -150,7 +151,7 @@ export const find: Find = async function find(
|
||||
transform({
|
||||
adapter: this,
|
||||
data: result.docs,
|
||||
fields: this.payload.collections[collection].config.fields,
|
||||
fields: collectionConfig.fields,
|
||||
operation: 'read',
|
||||
})
|
||||
|
||||
|
||||
@@ -7,15 +7,16 @@ import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getGlobal } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const findGlobal: FindGlobal = async function findGlobal(
|
||||
this: MongooseAdapter,
|
||||
{ slug, locale, req, select, where },
|
||||
{ slug: globalSlug, locale, req, select, where = {} },
|
||||
) {
|
||||
const Model = this.globals
|
||||
const globalConfig = this.payload.globals.config.find((each) => each.slug === slug)
|
||||
const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug })
|
||||
|
||||
const fields = globalConfig.flattenedFields
|
||||
const options: QueryOptions = {
|
||||
lean: true,
|
||||
@@ -30,12 +31,12 @@ export const findGlobal: FindGlobal = async function findGlobal(
|
||||
const query = await buildQuery({
|
||||
adapter: this,
|
||||
fields,
|
||||
globalSlug: slug,
|
||||
globalSlug,
|
||||
locale,
|
||||
where: combineQueries({ globalType: { equals: slug } }, where),
|
||||
where: combineQueries({ globalType: { equals: globalSlug } }, where),
|
||||
})
|
||||
|
||||
const doc = (await Model.findOne(query, {}, options)) as any
|
||||
const doc: any = await Model.findOne(query, {}, options)
|
||||
|
||||
if (!doc) {
|
||||
return null
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
import type { PaginateOptions, QueryOptions } from 'mongoose'
|
||||
import type { FindGlobalVersions } from 'payload'
|
||||
|
||||
import { buildVersionGlobalFields, flattenWhereToOperators } from 'payload'
|
||||
import { APIError, buildVersionGlobalFields, flattenWhereToOperators } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { buildSortParam } from './queries/buildSortParam.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getGlobal } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const findGlobalVersions: FindGlobalVersions = async function findGlobalVersions(
|
||||
this: MongooseAdapter,
|
||||
{ global, limit, locale, page, pagination, req, select, skip, sort: sortArg, where },
|
||||
{
|
||||
global: globalSlug,
|
||||
limit,
|
||||
locale,
|
||||
page,
|
||||
pagination,
|
||||
req,
|
||||
select,
|
||||
skip,
|
||||
sort: sortArg,
|
||||
where = {},
|
||||
},
|
||||
) {
|
||||
const globalConfig = this.payload.globals.config.find(({ slug }) => slug === global)
|
||||
const Model = this.versions[global]
|
||||
const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug, versions: true })
|
||||
|
||||
const versionFields = buildVersionGlobalFields(this.payload.config, globalConfig, true)
|
||||
|
||||
const session = await getSession(this, req)
|
||||
@@ -88,10 +100,11 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV
|
||||
}
|
||||
}
|
||||
|
||||
if (limit >= 0) {
|
||||
if (limit && limit >= 0) {
|
||||
paginationOptions.limit = limit
|
||||
// limit must also be set here, it's ignored when pagination is false
|
||||
paginationOptions.options.limit = limit
|
||||
|
||||
paginationOptions.options!.limit = limit
|
||||
|
||||
// Disable pagination if limit is 0
|
||||
if (limit === 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AggregateOptions, QueryOptions } from 'mongoose'
|
||||
import type { FindOne } from 'payload'
|
||||
|
||||
import { type FindOne } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
@@ -7,15 +8,16 @@ import { buildQuery } from './queries/buildQuery.js'
|
||||
import { aggregatePaginate } from './utilities/aggregatePaginate.js'
|
||||
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const findOne: FindOne = async function findOne(
|
||||
this: MongooseAdapter,
|
||||
{ collection, joins, locale, req, select, where },
|
||||
{ collection: collectionSlug, joins, locale, req, select, where = {} },
|
||||
) {
|
||||
const Model = this.collections[collection]
|
||||
const collectionConfig = this.payload.collections[collection].config
|
||||
const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug })
|
||||
|
||||
const session = await getSession(this, req)
|
||||
const options: AggregateOptions & QueryOptions = {
|
||||
lean: true,
|
||||
@@ -24,7 +26,7 @@ export const findOne: FindOne = async function findOne(
|
||||
|
||||
const query = await buildQuery({
|
||||
adapter: this,
|
||||
collectionSlug: collection,
|
||||
collectionSlug,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
locale,
|
||||
where,
|
||||
@@ -38,7 +40,7 @@ export const findOne: FindOne = async function findOne(
|
||||
|
||||
const aggregate = await buildJoinAggregation({
|
||||
adapter: this,
|
||||
collection,
|
||||
collection: collectionSlug,
|
||||
collectionConfig,
|
||||
joins,
|
||||
locale,
|
||||
|
||||
@@ -8,15 +8,31 @@ import type { MongooseAdapter } from './index.js'
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { buildSortParam } from './queries/buildSortParam.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const findVersions: FindVersions = async function findVersions(
|
||||
this: MongooseAdapter,
|
||||
{ collection, limit, locale, page, pagination, req = {}, select, skip, sort: sortArg, where },
|
||||
{
|
||||
collection: collectionSlug,
|
||||
limit,
|
||||
locale,
|
||||
page,
|
||||
pagination,
|
||||
req = {},
|
||||
select,
|
||||
skip,
|
||||
sort: sortArg,
|
||||
where = {},
|
||||
},
|
||||
) {
|
||||
const Model = this.versions[collection]
|
||||
const collectionConfig = this.payload.collections[collection].config
|
||||
const { collectionConfig, Model } = getCollection({
|
||||
adapter: this,
|
||||
collectionSlug,
|
||||
versions: true,
|
||||
})
|
||||
|
||||
const session = await getSession(this, req)
|
||||
const options: QueryOptions = {
|
||||
limit,
|
||||
@@ -92,10 +108,11 @@ export const findVersions: FindVersions = async function findVersions(
|
||||
}
|
||||
}
|
||||
|
||||
if (limit >= 0) {
|
||||
if (limit && limit >= 0) {
|
||||
paginationOptions.limit = limit
|
||||
// limit must also be set here, it's ignored when pagination is false
|
||||
paginationOptions.options.limit = limit
|
||||
|
||||
paginationOptions.options!.limit = limit
|
||||
|
||||
// Disable pagination if limit is 0
|
||||
if (limit === 0) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
BaseDatabaseAdapter,
|
||||
CollectionSlug,
|
||||
DatabaseAdapterObj,
|
||||
Migration,
|
||||
Payload,
|
||||
TypeWithID,
|
||||
TypeWithVersion,
|
||||
@@ -110,11 +111,7 @@ export interface Args {
|
||||
* typed as any to avoid dependency
|
||||
*/
|
||||
mongoMemoryServer?: MongoMemoryReplSet
|
||||
prodMigrations?: {
|
||||
down: (args: MigrateDownArgs) => Promise<void>
|
||||
name: string
|
||||
up: (args: MigrateUpArgs) => Promise<void>
|
||||
}[]
|
||||
prodMigrations?: Migration[]
|
||||
transactionOptions?: false | TransactionOptions
|
||||
|
||||
/** The URL to connect to MongoDB or false to start payload and prevent connecting */
|
||||
@@ -181,7 +178,7 @@ export function mongooseAdapter({
|
||||
collectionsSchemaOptions = {},
|
||||
connectOptions,
|
||||
disableIndexHints = false,
|
||||
ensureIndexes,
|
||||
ensureIndexes = false,
|
||||
migrationDir: migrationDirArg,
|
||||
mongoMemoryServer,
|
||||
prodMigrations,
|
||||
@@ -198,11 +195,14 @@ export function mongooseAdapter({
|
||||
// Mongoose-specific
|
||||
autoPluralization,
|
||||
collections: {},
|
||||
// @ts-expect-error initialize without a connection
|
||||
connection: undefined,
|
||||
connectOptions: connectOptions || {},
|
||||
disableIndexHints,
|
||||
ensureIndexes,
|
||||
// @ts-expect-error don't have globals model yet
|
||||
globals: undefined,
|
||||
// @ts-expect-error Should not be required
|
||||
mongoMemoryServer,
|
||||
sessions: {},
|
||||
transactionOptions: transactionOptions === false ? undefined : transactionOptions,
|
||||
|
||||
@@ -3,10 +3,14 @@ import type { Init, SanitizedCollectionConfig } from 'payload'
|
||||
|
||||
import mongoose from 'mongoose'
|
||||
import paginate from 'mongoose-paginate-v2'
|
||||
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
|
||||
import {
|
||||
buildVersionCollectionFields,
|
||||
buildVersionCompoundIndexes,
|
||||
buildVersionGlobalFields,
|
||||
} from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
import type { CollectionModel } from './types.js'
|
||||
import type { CollectionModel, GlobalModel } from './types.js'
|
||||
|
||||
import { buildCollectionSchema } from './models/buildCollectionSchema.js'
|
||||
import { buildGlobalModel } from './models/buildGlobalModel.js'
|
||||
@@ -16,7 +20,7 @@ import { getDBName } from './utilities/getDBName.js'
|
||||
|
||||
export const init: Init = function init(this: MongooseAdapter) {
|
||||
this.payload.config.collections.forEach((collection: SanitizedCollectionConfig) => {
|
||||
const schemaOptions = this.collectionsSchemaOptions[collection.slug]
|
||||
const schemaOptions = this.collectionsSchemaOptions?.[collection.slug]
|
||||
|
||||
const schema = buildCollectionSchema(collection, this.payload, schemaOptions)
|
||||
|
||||
@@ -36,6 +40,7 @@ export const init: Init = function init(this: MongooseAdapter) {
|
||||
},
|
||||
...schemaOptions,
|
||||
},
|
||||
compoundIndexes: buildVersionCompoundIndexes({ indexes: collection.sanitizedIndexes }),
|
||||
configFields: versionCollectionFields,
|
||||
payload: this.payload,
|
||||
})
|
||||
@@ -68,7 +73,7 @@ export const init: Init = function init(this: MongooseAdapter) {
|
||||
) as CollectionModel
|
||||
})
|
||||
|
||||
this.globals = buildGlobalModel(this.payload)
|
||||
this.globals = buildGlobalModel(this.payload) as GlobalModel
|
||||
|
||||
this.payload.config.globals.forEach((global) => {
|
||||
if (global.versions) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { PayloadRequest } from 'payload'
|
||||
|
||||
import { commitTransaction, initTransaction, killTransaction, readMigrationFiles } from 'payload'
|
||||
import prompts from 'prompts'
|
||||
|
||||
|
||||
@@ -23,12 +23,13 @@ export const buildCollectionSchema = (
|
||||
...schemaOptions,
|
||||
},
|
||||
},
|
||||
compoundIndexes: collection.sanitizedIndexes,
|
||||
configFields: collection.fields,
|
||||
payload,
|
||||
})
|
||||
|
||||
if (Array.isArray(collection.upload.filenameCompoundIndex)) {
|
||||
const indexDefinition: Record<string, 1> = collection.upload.filenameCompoundIndex.reduce(
|
||||
const indexDefinition = collection.upload.filenameCompoundIndex.reduce<Record<string, 1>>(
|
||||
(acc, index) => {
|
||||
acc[index] = 1
|
||||
return acc
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
import type { ClientSession, Model } from 'mongoose'
|
||||
import type { Field, PayloadRequest, SanitizedConfig } from 'payload'
|
||||
import type { Field, PayloadRequest } from 'payload'
|
||||
|
||||
import { buildVersionCollectionFields, buildVersionGlobalFields } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from '../index.js'
|
||||
|
||||
import { getCollection, getGlobal } from '../utilities/getEntity.js'
|
||||
import { getSession } from '../utilities/getSession.js'
|
||||
import { transform } from '../utilities/transform.js'
|
||||
|
||||
const migrateModelWithBatching = async ({
|
||||
batchSize,
|
||||
config,
|
||||
db,
|
||||
fields,
|
||||
Model,
|
||||
@@ -18,12 +18,11 @@ const migrateModelWithBatching = async ({
|
||||
session,
|
||||
}: {
|
||||
batchSize: number
|
||||
config: SanitizedConfig
|
||||
db: MongooseAdapter
|
||||
fields: Field[]
|
||||
Model: Model<any>
|
||||
parentIsLocalized: boolean
|
||||
session: ClientSession
|
||||
session?: ClientSession
|
||||
}): Promise<void> => {
|
||||
let hasNext = true
|
||||
let skip = 0
|
||||
@@ -55,6 +54,7 @@ const migrateModelWithBatching = async ({
|
||||
}
|
||||
|
||||
await Model.collection.bulkWrite(
|
||||
// @ts-expect-error bulkWrite has a weird type, insertOne, updateMany etc are required here as well.
|
||||
docs.map((doc) => ({
|
||||
updateOne: {
|
||||
filter: { _id: doc._id },
|
||||
@@ -123,12 +123,13 @@ export async function migrateRelationshipsV2_V3({
|
||||
if (hasRelationshipOrUploadField(collection)) {
|
||||
payload.logger.info(`Migrating collection "${collection.slug}"`)
|
||||
|
||||
const { Model } = getCollection({ adapter: db, collectionSlug: collection.slug })
|
||||
|
||||
await migrateModelWithBatching({
|
||||
batchSize,
|
||||
config,
|
||||
db,
|
||||
fields: collection.fields,
|
||||
Model: db.collections[collection.slug],
|
||||
Model,
|
||||
parentIsLocalized: false,
|
||||
session,
|
||||
})
|
||||
@@ -139,12 +140,17 @@ export async function migrateRelationshipsV2_V3({
|
||||
if (collection.versions) {
|
||||
payload.logger.info(`Migrating collection versions "${collection.slug}"`)
|
||||
|
||||
const { Model } = getCollection({
|
||||
adapter: db,
|
||||
collectionSlug: collection.slug,
|
||||
versions: true,
|
||||
})
|
||||
|
||||
await migrateModelWithBatching({
|
||||
batchSize,
|
||||
config,
|
||||
db,
|
||||
fields: buildVersionCollectionFields(config, collection),
|
||||
Model: db.versions[collection.slug],
|
||||
Model,
|
||||
parentIsLocalized: false,
|
||||
session,
|
||||
})
|
||||
@@ -193,12 +199,13 @@ export async function migrateRelationshipsV2_V3({
|
||||
if (global.versions) {
|
||||
payload.logger.info(`Migrating global versions "${global.slug}"`)
|
||||
|
||||
const { Model } = getGlobal({ adapter: db, globalSlug: global.slug, versions: true })
|
||||
|
||||
await migrateModelWithBatching({
|
||||
batchSize,
|
||||
config,
|
||||
db,
|
||||
fields: buildVersionGlobalFields(config, global),
|
||||
Model: db.versions[global.slug],
|
||||
Model,
|
||||
parentIsLocalized: false,
|
||||
session,
|
||||
})
|
||||
|
||||
@@ -3,18 +3,20 @@ import type { Payload, PayloadRequest } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from '../index.js'
|
||||
|
||||
import { getCollection, getGlobal } from '../utilities/getEntity.js'
|
||||
import { getSession } from '../utilities/getSession.js'
|
||||
|
||||
export async function migrateVersionsV1_V2({ req }: { req: PayloadRequest }) {
|
||||
const { payload } = req
|
||||
|
||||
const session = await getSession(payload.db as MongooseAdapter, req)
|
||||
const adapter = payload.db as MongooseAdapter
|
||||
const session = await getSession(adapter, req)
|
||||
|
||||
// For each collection
|
||||
|
||||
for (const { slug, versions } of payload.config.collections) {
|
||||
if (versions?.drafts) {
|
||||
await migrateCollectionDocs({ slug, payload, session })
|
||||
await migrateCollectionDocs({ slug, adapter, payload, session })
|
||||
|
||||
payload.logger.info(`Migrated the "${slug}" collection.`)
|
||||
}
|
||||
@@ -23,9 +25,13 @@ export async function migrateVersionsV1_V2({ req }: { req: PayloadRequest }) {
|
||||
// For each global
|
||||
for (const { slug, versions } of payload.config.globals) {
|
||||
if (versions) {
|
||||
const VersionsModel = payload.db.versions[slug]
|
||||
const { Model } = getGlobal({
|
||||
adapter,
|
||||
globalSlug: slug,
|
||||
versions: true,
|
||||
})
|
||||
|
||||
await VersionsModel.findOneAndUpdate(
|
||||
await Model.findOneAndUpdate(
|
||||
{},
|
||||
{ latest: true },
|
||||
{
|
||||
@@ -41,17 +47,23 @@ export async function migrateVersionsV1_V2({ req }: { req: PayloadRequest }) {
|
||||
|
||||
async function migrateCollectionDocs({
|
||||
slug,
|
||||
adapter,
|
||||
docsAtATime = 100,
|
||||
payload,
|
||||
session,
|
||||
}: {
|
||||
adapter: MongooseAdapter
|
||||
docsAtATime?: number
|
||||
payload: Payload
|
||||
session: ClientSession
|
||||
session?: ClientSession
|
||||
slug: string
|
||||
}) {
|
||||
const VersionsModel = payload.db.versions[slug]
|
||||
const remainingDocs = await VersionsModel.aggregate(
|
||||
const { Model } = getCollection({
|
||||
adapter,
|
||||
collectionSlug: slug,
|
||||
versions: true,
|
||||
})
|
||||
const remainingDocs = await Model.aggregate(
|
||||
[
|
||||
// Sort so that newest are first
|
||||
{
|
||||
@@ -87,7 +99,7 @@ async function migrateCollectionDocs({
|
||||
).exec()
|
||||
|
||||
if (!remainingDocs || remainingDocs.length === 0) {
|
||||
const newVersions = await VersionsModel.find(
|
||||
const newVersions = await Model.find(
|
||||
{
|
||||
latest: {
|
||||
$eq: true,
|
||||
@@ -108,7 +120,7 @@ async function migrateCollectionDocs({
|
||||
|
||||
const remainingDocIDs = remainingDocs.map((doc) => doc._versionID)
|
||||
|
||||
await VersionsModel.updateMany(
|
||||
await Model.updateMany(
|
||||
{
|
||||
_id: {
|
||||
$in: remainingDocIDs,
|
||||
@@ -122,5 +134,5 @@ async function migrateCollectionDocs({
|
||||
},
|
||||
)
|
||||
|
||||
await migrateCollectionDocs({ slug, payload, session })
|
||||
await migrateCollectionDocs({ slug, adapter, payload, session })
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { FilterQuery } from 'mongoose'
|
||||
import type { FlattenedField, Operator, PathToQuery, Payload } from 'payload'
|
||||
|
||||
import { Types } from 'mongoose'
|
||||
import { getLocalizedPaths } from 'payload'
|
||||
import { APIError, getLocalizedPaths } from 'payload'
|
||||
import { validOperatorSet } from 'payload/shared'
|
||||
|
||||
import type { MongooseAdapter } from '../index.js'
|
||||
import type { OperatorMapKey } from './operatorMap.js'
|
||||
|
||||
import { getCollection } from '../utilities/getEntity.js'
|
||||
import { operatorMap } from './operatorMap.js'
|
||||
import { sanitizeQueryValue } from './sanitizeQueryValue.js'
|
||||
|
||||
@@ -43,7 +46,7 @@ export async function buildSearchParam({
|
||||
parentIsLocalized: boolean
|
||||
payload: Payload
|
||||
val: unknown
|
||||
}): Promise<SearchParam> {
|
||||
}): Promise<SearchParam | undefined> {
|
||||
// Replace GraphQL nested field double underscore formatting
|
||||
let sanitizedPath = incomingPath.replace(/__/g, '.')
|
||||
if (sanitizedPath === 'id') {
|
||||
@@ -55,7 +58,9 @@ export async function buildSearchParam({
|
||||
let hasCustomID = false
|
||||
|
||||
if (sanitizedPath === '_id') {
|
||||
const customIDFieldType = payload.collections[collectionSlug]?.customIDType
|
||||
const customIDFieldType = collectionSlug
|
||||
? payload.collections[collectionSlug]?.customIDType
|
||||
: undefined
|
||||
|
||||
let idFieldType: 'number' | 'text' = 'text'
|
||||
|
||||
@@ -71,7 +76,7 @@ export async function buildSearchParam({
|
||||
name: 'id',
|
||||
type: idFieldType,
|
||||
} as FlattenedField,
|
||||
parentIsLocalized,
|
||||
parentIsLocalized: parentIsLocalized ?? false,
|
||||
path: '_id',
|
||||
})
|
||||
} else {
|
||||
@@ -86,6 +91,10 @@ export async function buildSearchParam({
|
||||
})
|
||||
}
|
||||
|
||||
if (!paths[0]) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [{ field, path }] = paths
|
||||
if (path) {
|
||||
const sanitizedQueryValue = sanitizeQueryValue({
|
||||
@@ -109,6 +118,10 @@ export async function buildSearchParam({
|
||||
return { value: rawQuery }
|
||||
}
|
||||
|
||||
if (!formattedOperator) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// If there are multiple collections to search through,
|
||||
// Recursively build up a list of query constraints
|
||||
if (paths.length > 1) {
|
||||
@@ -116,84 +129,86 @@ export async function buildSearchParam({
|
||||
// to work backwards from top
|
||||
const pathsToQuery = paths.slice(1).reverse()
|
||||
|
||||
const initialRelationshipQuery = {
|
||||
let relationshipQuery: SearchParam = {
|
||||
value: {},
|
||||
} as SearchParam
|
||||
}
|
||||
|
||||
const relationshipQuery = await pathsToQuery.reduce(
|
||||
async (priorQuery, { collectionSlug: slug, path: subPath }, i) => {
|
||||
const priorQueryResult = await priorQuery
|
||||
for (const [i, { collectionSlug, path: subPath }] of pathsToQuery.entries()) {
|
||||
if (!collectionSlug) {
|
||||
throw new APIError(`Collection with the slug ${collectionSlug} was not found.`)
|
||||
}
|
||||
|
||||
const SubModel = (payload.db as MongooseAdapter).collections[slug]
|
||||
const { Model: SubModel } = getCollection({
|
||||
adapter: payload.db as MongooseAdapter,
|
||||
collectionSlug,
|
||||
})
|
||||
|
||||
// On the "deepest" collection,
|
||||
// Search on the value passed through the query
|
||||
if (i === 0) {
|
||||
const subQuery = await SubModel.buildQuery({
|
||||
locale,
|
||||
payload,
|
||||
where: {
|
||||
[subPath]: {
|
||||
[formattedOperator]: val,
|
||||
},
|
||||
if (i === 0) {
|
||||
const subQuery = await SubModel.buildQuery({
|
||||
locale,
|
||||
payload,
|
||||
where: {
|
||||
[subPath]: {
|
||||
[formattedOperator]: val,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const result = await SubModel.find(subQuery, subQueryOptions)
|
||||
|
||||
const $in: unknown[] = []
|
||||
|
||||
result.forEach((doc) => {
|
||||
const stringID = doc._id.toString()
|
||||
$in.push(stringID)
|
||||
|
||||
if (Types.ObjectId.isValid(stringID)) {
|
||||
$in.push(doc._id)
|
||||
}
|
||||
})
|
||||
|
||||
if (pathsToQuery.length === 1) {
|
||||
return {
|
||||
path,
|
||||
value: { $in },
|
||||
}
|
||||
}
|
||||
|
||||
const nextSubPath = pathsToQuery[i + 1].path
|
||||
|
||||
return {
|
||||
value: { [nextSubPath]: { $in } },
|
||||
}
|
||||
}
|
||||
|
||||
const subQuery = priorQueryResult.value
|
||||
const result = await SubModel.find(subQuery, subQueryOptions)
|
||||
|
||||
const $in = result.map((doc) => doc._id)
|
||||
const $in: unknown[] = []
|
||||
|
||||
// If it is the last recursion
|
||||
// then pass through the search param
|
||||
if (i + 1 === pathsToQuery.length) {
|
||||
result.forEach((doc) => {
|
||||
const stringID = doc._id.toString()
|
||||
$in.push(stringID)
|
||||
|
||||
if (Types.ObjectId.isValid(stringID)) {
|
||||
$in.push(doc._id)
|
||||
}
|
||||
})
|
||||
|
||||
if (pathsToQuery.length === 1) {
|
||||
return {
|
||||
path,
|
||||
value: { $in },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
const nextSubPath = pathsToQuery[i + 1]?.path
|
||||
|
||||
if (nextSubPath) {
|
||||
relationshipQuery = { value: { [nextSubPath]: $in } }
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const subQuery = relationshipQuery.value as FilterQuery<any>
|
||||
const result = await SubModel.find(subQuery, subQueryOptions)
|
||||
|
||||
const $in = result.map((doc) => doc._id)
|
||||
|
||||
// If it is the last recursion
|
||||
// then pass through the search param
|
||||
if (i + 1 === pathsToQuery.length) {
|
||||
relationshipQuery = {
|
||||
path,
|
||||
value: { $in },
|
||||
}
|
||||
} else {
|
||||
relationshipQuery = {
|
||||
value: {
|
||||
_id: { $in },
|
||||
},
|
||||
}
|
||||
},
|
||||
Promise.resolve(initialRelationshipQuery),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return relationshipQuery
|
||||
}
|
||||
|
||||
if (formattedOperator && validOperatorSet.has(formattedOperator as Operator)) {
|
||||
const operatorKey = operatorMap[formattedOperator]
|
||||
const operatorKey = operatorMap[formattedOperator as OperatorMapKey]
|
||||
|
||||
if (field.type === 'relationship' || field.type === 'upload') {
|
||||
let hasNumberIDRelation
|
||||
@@ -210,7 +225,7 @@ export async function buildSearchParam({
|
||||
|
||||
if (typeof formattedValue === 'string') {
|
||||
if (Types.ObjectId.isValid(formattedValue)) {
|
||||
result.value[multiIDCondition].push({
|
||||
result.value[multiIDCondition]?.push({
|
||||
[path]: { [operatorKey]: new Types.ObjectId(formattedValue) },
|
||||
})
|
||||
} else {
|
||||
@@ -226,14 +241,16 @@ export async function buildSearchParam({
|
||||
)
|
||||
|
||||
if (hasNumberIDRelation) {
|
||||
result.value[multiIDCondition].push({
|
||||
result.value[multiIDCondition]?.push({
|
||||
[path]: { [operatorKey]: parseFloat(formattedValue) },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result.value[multiIDCondition].length > 1) {
|
||||
const length = result.value[multiIDCondition]?.length
|
||||
|
||||
if (typeof length === 'number' && length > 1) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { PaginateOptions } from 'mongoose'
|
||||
import type { FlattenedField, SanitizedConfig, Sort } from 'payload'
|
||||
|
||||
import { getLocalizedSortProperty } from './getLocalizedSortProperty.js'
|
||||
@@ -6,7 +5,7 @@ import { getLocalizedSortProperty } from './getLocalizedSortProperty.js'
|
||||
type Args = {
|
||||
config: SanitizedConfig
|
||||
fields: FlattenedField[]
|
||||
locale: string
|
||||
locale?: string
|
||||
parentIsLocalized?: boolean
|
||||
sort: Sort
|
||||
timestamps: boolean
|
||||
@@ -23,10 +22,10 @@ export const buildSortParam = ({
|
||||
config,
|
||||
fields,
|
||||
locale,
|
||||
parentIsLocalized,
|
||||
parentIsLocalized = false,
|
||||
sort,
|
||||
timestamps,
|
||||
}: Args): PaginateOptions['sort'] => {
|
||||
}: Args): Record<string, string> => {
|
||||
if (!sort) {
|
||||
if (timestamps) {
|
||||
sort = '-createdAt'
|
||||
@@ -39,7 +38,7 @@ export const buildSortParam = ({
|
||||
sort = [sort]
|
||||
}
|
||||
|
||||
const sorting = sort.reduce<PaginateOptions['sort']>((acc, item) => {
|
||||
const sorting = sort.reduce<Record<string, string>>((acc, item) => {
|
||||
let sortProperty: string
|
||||
let sortDirection: SortDirection
|
||||
if (item.indexOf('-') === 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FlattenedField, Payload, Where } from 'payload'
|
||||
|
||||
import { QueryError } from 'payload'
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import { parseParams } from './parseParams.js'
|
||||
|
||||
@@ -23,7 +23,7 @@ export const getBuildQueryPlugin = ({
|
||||
collectionSlug,
|
||||
versionsFields,
|
||||
}: GetBuildQueryPluginArgs = {}) => {
|
||||
return function buildQueryPlugin(schema) {
|
||||
return function buildQueryPlugin(schema: any) {
|
||||
const modifiedSchema = schema
|
||||
async function schemaBuildQuery({
|
||||
globalSlug,
|
||||
@@ -31,19 +31,35 @@ export const getBuildQueryPlugin = ({
|
||||
payload,
|
||||
where,
|
||||
}: BuildQueryArgs): Promise<Record<string, unknown>> {
|
||||
let fields = versionsFields
|
||||
if (!fields) {
|
||||
let fields: FlattenedField[] | null = null
|
||||
|
||||
if (versionsFields) {
|
||||
fields = versionsFields
|
||||
} else {
|
||||
if (globalSlug) {
|
||||
const globalConfig = payload.globals.config.find(({ slug }) => slug === globalSlug)
|
||||
|
||||
if (!globalConfig) {
|
||||
throw new APIError(`Global with the slug ${globalSlug} was not found`)
|
||||
}
|
||||
|
||||
fields = globalConfig.flattenedFields
|
||||
}
|
||||
if (collectionSlug) {
|
||||
const collectionConfig = payload.collections[collectionSlug].config
|
||||
const collectionConfig = payload.collections[collectionSlug]?.config
|
||||
|
||||
if (!collectionConfig) {
|
||||
throw new APIError(`Collection with the slug ${globalSlug} was not found`)
|
||||
}
|
||||
|
||||
fields = collectionConfig.flattenedFields
|
||||
}
|
||||
}
|
||||
|
||||
const errors = []
|
||||
if (fields === null) {
|
||||
throw new APIError('Fields are not initialized.')
|
||||
}
|
||||
|
||||
const result = await parseParams({
|
||||
collectionSlug,
|
||||
fields,
|
||||
@@ -54,10 +70,6 @@ export const getBuildQueryPlugin = ({
|
||||
where,
|
||||
})
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new QueryError(errors)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
modifiedSchema.statics.buildQuery = schemaBuildQuery
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('get localized sort property', () => {
|
||||
it('passes through a non-localized sort property', () => {
|
||||
const result = getLocalizedSortProperty({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
@@ -35,6 +36,7 @@ describe('get localized sort property', () => {
|
||||
it('properly localizes an un-localized sort property', () => {
|
||||
const result = getLocalizedSortProperty({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
@@ -52,6 +54,7 @@ describe('get localized sort property', () => {
|
||||
it('keeps specifically asked-for localized sort properties', () => {
|
||||
const result = getLocalizedSortProperty({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
@@ -69,6 +72,7 @@ describe('get localized sort property', () => {
|
||||
it('properly localizes nested sort properties', () => {
|
||||
const result = getLocalizedSortProperty({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: flattenAllFields({
|
||||
fields: [
|
||||
{
|
||||
@@ -94,6 +98,7 @@ describe('get localized sort property', () => {
|
||||
it('keeps requested locale with nested sort properties', () => {
|
||||
const result = getLocalizedSortProperty({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: flattenAllFields({
|
||||
fields: [
|
||||
{
|
||||
@@ -119,6 +124,7 @@ describe('get localized sort property', () => {
|
||||
it('properly localizes field within row', () => {
|
||||
const result = getLocalizedSortProperty({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: flattenAllFields({
|
||||
fields: [
|
||||
{
|
||||
@@ -143,6 +149,7 @@ describe('get localized sort property', () => {
|
||||
it('properly localizes field within named tab', () => {
|
||||
const result = getLocalizedSortProperty({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: flattenAllFields({
|
||||
fields: [
|
||||
{
|
||||
@@ -172,6 +179,7 @@ describe('get localized sort property', () => {
|
||||
it('properly localizes field within unnamed tab', () => {
|
||||
const result = getLocalizedSortProperty({
|
||||
config,
|
||||
parentIsLocalized: false,
|
||||
fields: flattenAllFields({
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fieldAffectsData, fieldIsPresentationalOnly, fieldShouldBeLocalized } f
|
||||
type Args = {
|
||||
config: SanitizedConfig
|
||||
fields: FlattenedField[]
|
||||
locale: string
|
||||
locale?: string
|
||||
parentIsLocalized: boolean
|
||||
result?: string
|
||||
segments: string[]
|
||||
@@ -36,14 +36,16 @@ export const getLocalizedSortProperty = ({
|
||||
)
|
||||
|
||||
if (matchedField && !fieldIsPresentationalOnly(matchedField)) {
|
||||
let nextFields: FlattenedField[]
|
||||
let nextFields: FlattenedField[] | null = null
|
||||
let nextParentIsLocalized = parentIsLocalized
|
||||
const remainingSegments = [...segments]
|
||||
let localizedSegment = matchedField.name
|
||||
|
||||
if (fieldShouldBeLocalized({ field: matchedField, parentIsLocalized })) {
|
||||
if (
|
||||
fieldShouldBeLocalized({ field: matchedField, parentIsLocalized: parentIsLocalized ?? false })
|
||||
) {
|
||||
// Check to see if next segment is a locale
|
||||
if (segments.length > 0) {
|
||||
if (segments.length > 0 && remainingSegments[0]) {
|
||||
const nextSegmentIsLocale = config.localization.localeCodes.includes(remainingSegments[0])
|
||||
|
||||
// If next segment is locale, remove it from remaining segments
|
||||
@@ -66,16 +68,21 @@ export const getLocalizedSortProperty = ({
|
||||
) {
|
||||
nextFields = matchedField.flattenedFields
|
||||
if (!nextParentIsLocalized) {
|
||||
nextParentIsLocalized = matchedField.localized
|
||||
nextParentIsLocalized = matchedField.localized ?? false
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedField.type === 'blocks') {
|
||||
nextFields = (matchedField.blockReferences ?? matchedField.blocks).reduce(
|
||||
nextFields = (matchedField.blockReferences ?? matchedField.blocks).reduce<FlattenedField[]>(
|
||||
(flattenedBlockFields, _block) => {
|
||||
// TODO: iterate over blocks mapped to block slug in v4, or pass through payload.blocks
|
||||
const block =
|
||||
typeof _block === 'string' ? config.blocks.find((b) => b.slug === _block) : _block
|
||||
typeof _block === 'string' ? config.blocks?.find((b) => b.slug === _block) : _block
|
||||
|
||||
if (!block) {
|
||||
return [...flattenedBlockFields]
|
||||
}
|
||||
|
||||
return [
|
||||
...flattenedBlockFields,
|
||||
...block.flattenedFields.filter(
|
||||
@@ -93,7 +100,7 @@ export const getLocalizedSortProperty = ({
|
||||
|
||||
const result = incomingResult ? `${incomingResult}.${localizedSegment}` : localizedSegment
|
||||
|
||||
if (nextFields) {
|
||||
if (nextFields !== null) {
|
||||
return getLocalizedSortProperty({
|
||||
config,
|
||||
fields: nextFields,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type OperatorMapKey = keyof typeof operatorMap
|
||||
|
||||
export const operatorMap = {
|
||||
all: '$all',
|
||||
equals: '$eq',
|
||||
|
||||
@@ -19,7 +19,7 @@ export async function parseParams({
|
||||
collectionSlug?: string
|
||||
fields: FlattenedField[]
|
||||
globalSlug?: string
|
||||
locale: string
|
||||
locale?: string
|
||||
parentIsLocalized: boolean
|
||||
payload: Payload
|
||||
where: Where
|
||||
@@ -30,7 +30,7 @@ export async function parseParams({
|
||||
// We need to determine if the whereKey is an AND, OR, or a schema path
|
||||
for (const relationOrPath of Object.keys(where)) {
|
||||
const condition = where[relationOrPath]
|
||||
let conditionOperator: '$and' | '$or'
|
||||
let conditionOperator: '$and' | '$or' | null = null
|
||||
if (relationOrPath.toLowerCase() === 'and') {
|
||||
conditionOperator = '$and'
|
||||
} else if (relationOrPath.toLowerCase() === 'or') {
|
||||
@@ -46,7 +46,7 @@ export async function parseParams({
|
||||
payload,
|
||||
where: condition,
|
||||
})
|
||||
if (builtConditions.length > 0) {
|
||||
if (builtConditions.length > 0 && conditionOperator !== null) {
|
||||
result[conditionOperator] = builtConditions
|
||||
}
|
||||
} else {
|
||||
@@ -58,6 +58,7 @@ export async function parseParams({
|
||||
const validOperators = Object.keys(pathOperators).filter((operator) =>
|
||||
validOperatorSet.has(operator as Operator),
|
||||
)
|
||||
|
||||
for (const operator of validOperators) {
|
||||
const searchParam = await buildSearchParam({
|
||||
collectionSlug,
|
||||
@@ -68,7 +69,7 @@ export async function parseParams({
|
||||
operator,
|
||||
parentIsLocalized,
|
||||
payload,
|
||||
val: pathOperators[operator],
|
||||
val: (pathOperators as Record<string, Where>)[operator],
|
||||
})
|
||||
|
||||
if (searchParam?.value && searchParam?.path) {
|
||||
@@ -83,7 +84,7 @@ export async function parseParams({
|
||||
result[searchParam.path] = searchParam.value
|
||||
}
|
||||
} else if (typeof searchParam?.value === 'object') {
|
||||
result = deepMergeWithCombinedArrays(result, searchParam.value, {
|
||||
result = deepMergeWithCombinedArrays(result, searchParam.value ?? {}, {
|
||||
// dont clone Types.ObjectIDs
|
||||
clone: false,
|
||||
})
|
||||
|
||||
@@ -21,7 +21,7 @@ type SanitizeQueryValueArgs = {
|
||||
val: any
|
||||
}
|
||||
|
||||
const buildExistsQuery = (formattedValue, path, treatEmptyString = true) => {
|
||||
const buildExistsQuery = (formattedValue: unknown, path: string, treatEmptyString = true) => {
|
||||
if (formattedValue) {
|
||||
return {
|
||||
rawQuery: {
|
||||
@@ -54,14 +54,17 @@ const getFieldFromSegments = ({
|
||||
field: FlattenedBlock | FlattenedField
|
||||
payload: Payload
|
||||
segments: string[]
|
||||
}) => {
|
||||
}): FlattenedField | undefined => {
|
||||
if ('blocks' in field || 'blockReferences' in field) {
|
||||
const _field: FlattenedBlocksField = field as FlattenedBlocksField
|
||||
for (const _block of _field.blockReferences ?? _field.blocks) {
|
||||
const block: FlattenedBlock = typeof _block === 'string' ? payload.blocks[_block] : _block
|
||||
const field = getFieldFromSegments({ field: block, payload, segments })
|
||||
if (field) {
|
||||
return field
|
||||
const block: FlattenedBlock | undefined =
|
||||
typeof _block === 'string' ? payload.blocks[_block] : _block
|
||||
if (block) {
|
||||
const field = getFieldFromSegments({ field: block, payload, segments })
|
||||
if (field) {
|
||||
return field
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,11 +96,13 @@ export const sanitizeQueryValue = ({
|
||||
path,
|
||||
payload,
|
||||
val,
|
||||
}: SanitizeQueryValueArgs): {
|
||||
operator?: string
|
||||
rawQuery?: unknown
|
||||
val?: unknown
|
||||
} => {
|
||||
}: SanitizeQueryValueArgs):
|
||||
| {
|
||||
operator?: string
|
||||
rawQuery?: unknown
|
||||
val?: unknown
|
||||
}
|
||||
| undefined => {
|
||||
let formattedValue = val
|
||||
let formattedOperator = operator
|
||||
if (['array', 'blocks', 'group', 'tab'].includes(field.type) && path.includes('.')) {
|
||||
@@ -141,24 +146,26 @@ export const sanitizeQueryValue = ({
|
||||
formattedValue = createArrayFromCommaDelineated(val)
|
||||
}
|
||||
|
||||
formattedValue = formattedValue.reduce((formattedValues, inVal) => {
|
||||
if (!hasCustomID) {
|
||||
if (Types.ObjectId.isValid(inVal)) {
|
||||
formattedValues.push(new Types.ObjectId(inVal))
|
||||
if (Array.isArray(formattedValue)) {
|
||||
formattedValue = formattedValue.reduce<unknown[]>((formattedValues, inVal) => {
|
||||
if (!hasCustomID) {
|
||||
if (Types.ObjectId.isValid(inVal)) {
|
||||
formattedValues.push(new Types.ObjectId(inVal))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
const parsedNumber = parseFloat(inVal)
|
||||
if (!Number.isNaN(parsedNumber)) {
|
||||
formattedValues.push(parsedNumber)
|
||||
if (field.type === 'number') {
|
||||
const parsedNumber = parseFloat(inVal)
|
||||
if (!Number.isNaN(parsedNumber)) {
|
||||
formattedValues.push(parsedNumber)
|
||||
}
|
||||
} else {
|
||||
formattedValues.push(inVal)
|
||||
}
|
||||
} else {
|
||||
formattedValues.push(inVal)
|
||||
}
|
||||
|
||||
return formattedValues
|
||||
}, [])
|
||||
return formattedValues
|
||||
}, [])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +182,7 @@ export const sanitizeQueryValue = ({
|
||||
if (['all', 'in', 'not_in'].includes(operator) && typeof formattedValue === 'string') {
|
||||
formattedValue = createArrayFromCommaDelineated(formattedValue)
|
||||
|
||||
if (field.type === 'number') {
|
||||
if (field.type === 'number' && Array.isArray(formattedValue)) {
|
||||
formattedValue = formattedValue.map((arrayVal) => parseFloat(arrayVal))
|
||||
}
|
||||
}
|
||||
@@ -264,7 +271,7 @@ export const sanitizeQueryValue = ({
|
||||
return formattedValues
|
||||
}
|
||||
|
||||
if (typeof relationTo === 'string' && payload.collections[relationTo].customIDType) {
|
||||
if (typeof relationTo === 'string' && payload.collections[relationTo]?.customIDType) {
|
||||
if (payload.collections[relationTo].customIDType === 'number') {
|
||||
const parsedNumber = parseFloat(inVal)
|
||||
if (!Number.isNaN(parsedNumber)) {
|
||||
@@ -279,7 +286,7 @@ export const sanitizeQueryValue = ({
|
||||
|
||||
if (
|
||||
Array.isArray(relationTo) &&
|
||||
relationTo.some((relationTo) => !!payload.collections[relationTo].customIDType)
|
||||
relationTo.some((relationTo) => !!payload.collections[relationTo]?.customIDType)
|
||||
) {
|
||||
if (Types.ObjectId.isValid(inVal.toString())) {
|
||||
formattedValues.push(new Types.ObjectId(inVal))
|
||||
@@ -302,7 +309,7 @@ export const sanitizeQueryValue = ({
|
||||
(!Array.isArray(relationTo) || !path.endsWith('.relationTo'))
|
||||
) {
|
||||
if (typeof relationTo === 'string') {
|
||||
const customIDType = payload.collections[relationTo].customIDType
|
||||
const customIDType = payload.collections[relationTo]?.customIDType
|
||||
|
||||
if (customIDType) {
|
||||
if (customIDType === 'number') {
|
||||
@@ -320,7 +327,7 @@ export const sanitizeQueryValue = ({
|
||||
}
|
||||
} else {
|
||||
const hasCustomIDType = relationTo.some(
|
||||
(relationTo) => !!payload.collections[relationTo].customIDType,
|
||||
(relationTo) => !!payload.collections[relationTo]?.customIDType,
|
||||
)
|
||||
|
||||
if (hasCustomIDType) {
|
||||
|
||||
@@ -10,15 +10,31 @@ import { buildSortParam } from './queries/buildSortParam.js'
|
||||
import { aggregatePaginate } from './utilities/aggregatePaginate.js'
|
||||
import { buildJoinAggregation } from './utilities/buildJoinAggregation.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const queryDrafts: QueryDrafts = async function queryDrafts(
|
||||
this: MongooseAdapter,
|
||||
{ collection, joins, limit, locale, page, pagination, req, select, sort: sortArg, where },
|
||||
{
|
||||
collection: collectionSlug,
|
||||
joins,
|
||||
limit,
|
||||
locale,
|
||||
page,
|
||||
pagination,
|
||||
req,
|
||||
select,
|
||||
sort: sortArg,
|
||||
where = {},
|
||||
},
|
||||
) {
|
||||
const VersionModel = this.versions[collection]
|
||||
const collectionConfig = this.payload.collections[collection].config
|
||||
const { collectionConfig, Model } = getCollection({
|
||||
adapter: this,
|
||||
collectionSlug,
|
||||
versions: true,
|
||||
})
|
||||
|
||||
const options: QueryOptions = {
|
||||
session: await getSession(this, req),
|
||||
}
|
||||
@@ -89,24 +105,25 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
|
||||
// the correct indexed field
|
||||
paginationOptions.useCustomCountFn = () => {
|
||||
return Promise.resolve(
|
||||
VersionModel.countDocuments(versionQuery, {
|
||||
Model.countDocuments(versionQuery, {
|
||||
hint: { _id: 1 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (limit > 0) {
|
||||
if (limit && limit > 0) {
|
||||
paginationOptions.limit = limit
|
||||
// limit must also be set here, it's ignored when pagination is false
|
||||
paginationOptions.options.limit = limit
|
||||
|
||||
paginationOptions.options!.limit = limit
|
||||
}
|
||||
|
||||
let result
|
||||
|
||||
const aggregate = await buildJoinAggregation({
|
||||
adapter: this,
|
||||
collection,
|
||||
collection: collectionSlug,
|
||||
collectionConfig,
|
||||
joins,
|
||||
locale,
|
||||
@@ -122,17 +139,17 @@ export const queryDrafts: QueryDrafts = async function queryDrafts(
|
||||
collation: paginationOptions.collation,
|
||||
joinAggregation: aggregate,
|
||||
limit: paginationOptions.limit,
|
||||
Model: VersionModel,
|
||||
Model,
|
||||
page: paginationOptions.page,
|
||||
pagination: paginationOptions.pagination,
|
||||
projection: paginationOptions.projection,
|
||||
query: versionQuery,
|
||||
session: paginationOptions.options?.session,
|
||||
session: paginationOptions.options?.session ?? undefined,
|
||||
sort: paginationOptions.sort as object,
|
||||
useEstimatedCount: paginationOptions.useEstimatedCount,
|
||||
})
|
||||
} else {
|
||||
result = await VersionModel.paginate(versionQuery, paginationOptions)
|
||||
result = await Model.paginate(versionQuery, paginationOptions)
|
||||
}
|
||||
|
||||
transform({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { v4 as uuid } from 'uuid'
|
||||
import type { MongooseAdapter } from '../index.js'
|
||||
|
||||
// Needs await to fulfill the interface
|
||||
// @ts-expect-error TransactionOptions isn't compatible with BeginTransaction of the DatabaseAdapter interface.
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
export const beginTransaction: BeginTransaction = async function beginTransaction(
|
||||
this: MongooseAdapter,
|
||||
@@ -20,12 +21,13 @@ export const beginTransaction: BeginTransaction = async function beginTransactio
|
||||
const id = uuid()
|
||||
|
||||
if (!this.sessions[id]) {
|
||||
// @ts-expect-error BaseDatabaseAdapter and MongoosAdapter (that extends Base) sessions aren't compatible.
|
||||
this.sessions[id] = client.startSession()
|
||||
}
|
||||
if (this.sessions[id].inTransaction()) {
|
||||
if (this.sessions[id]?.inTransaction()) {
|
||||
this.payload.logger.warn('beginTransaction called while transaction already exists')
|
||||
} else {
|
||||
this.sessions[id].startTransaction(options || (this.transactionOptions as TransactionOptions))
|
||||
this.sessions[id]?.startTransaction(options || (this.transactionOptions as TransactionOptions))
|
||||
}
|
||||
|
||||
return id
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { CommitTransaction } from 'payload'
|
||||
|
||||
export const commitTransaction: CommitTransaction = async function commitTransaction(id) {
|
||||
import type { MongooseAdapter } from '../index.js'
|
||||
|
||||
export const commitTransaction: CommitTransaction = async function commitTransaction(
|
||||
this: MongooseAdapter,
|
||||
id,
|
||||
) {
|
||||
if (id instanceof Promise) {
|
||||
return
|
||||
}
|
||||
@@ -12,7 +17,7 @@ export const commitTransaction: CommitTransaction = async function commitTransac
|
||||
await this.sessions[id].commitTransaction()
|
||||
try {
|
||||
await this.sessions[id].endSession()
|
||||
} catch (error) {
|
||||
} catch (_) {
|
||||
// ending sessions is only best effort and won't impact anything if it fails since the transaction was committed
|
||||
}
|
||||
delete this.sessions[id]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { RollbackTransaction } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from '../index.js'
|
||||
|
||||
export const rollbackTransaction: RollbackTransaction = async function rollbackTransaction(
|
||||
this: MongooseAdapter,
|
||||
incomingID = '',
|
||||
) {
|
||||
let transactionID: number | string
|
||||
@@ -18,7 +21,7 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT
|
||||
}
|
||||
|
||||
// when session exists but is not inTransaction something unexpected is happening to the session
|
||||
if (!this.sessions[transactionID].inTransaction()) {
|
||||
if (!this.sessions[transactionID]?.inTransaction()) {
|
||||
this.payload.logger.warn('rollbackTransaction called when no transaction exists')
|
||||
delete this.sessions[transactionID]
|
||||
return
|
||||
@@ -26,8 +29,8 @@ export const rollbackTransaction: RollbackTransaction = async function rollbackT
|
||||
|
||||
// the first call for rollback should be aborted and deleted causing any other operations with the same transaction to fail
|
||||
try {
|
||||
await this.sessions[transactionID].abortTransaction()
|
||||
await this.sessions[transactionID].endSession()
|
||||
await this.sessions[transactionID]?.abortTransaction()
|
||||
await this.sessions[transactionID]?.endSession()
|
||||
} catch (error) {
|
||||
// ignore the error as it is likely a race condition from multiple errors
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ import type { UpdateGlobal } from 'payload'
|
||||
import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getGlobal } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const updateGlobal: UpdateGlobal = async function updateGlobal(
|
||||
this: MongooseAdapter,
|
||||
{ slug, data, options: optionsArgs = {}, req, returning, select },
|
||||
{ slug: globalSlug, data, options: optionsArgs = {}, req, returning, select },
|
||||
) {
|
||||
const Model = this.globals
|
||||
const fields = this.payload.config.globals.find((global) => global.slug === slug).fields
|
||||
const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug })
|
||||
|
||||
const fields = globalConfig.fields
|
||||
|
||||
const options: MongooseUpdateQueryOptions = {
|
||||
...optionsArgs,
|
||||
@@ -20,22 +22,22 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal(
|
||||
new: true,
|
||||
projection: buildProjectionFromSelect({
|
||||
adapter: this,
|
||||
fields: this.payload.config.globals.find((global) => global.slug === slug).flattenedFields,
|
||||
fields: globalConfig.flattenedFields,
|
||||
select,
|
||||
}),
|
||||
session: await getSession(this, req),
|
||||
}
|
||||
|
||||
transform({ adapter: this, data, fields, globalSlug: slug, operation: 'write' })
|
||||
transform({ adapter: this, data, fields, globalSlug, operation: 'write' })
|
||||
|
||||
if (returning === false) {
|
||||
await Model.updateOne({ globalType: slug }, data, options)
|
||||
await Model.updateOne({ globalType: globalSlug }, data, options)
|
||||
return null
|
||||
}
|
||||
|
||||
const result: any = await Model.findOneAndUpdate({ globalType: slug }, data, options)
|
||||
const result: any = await Model.findOneAndUpdate({ globalType: globalSlug }, data, options)
|
||||
|
||||
transform({ adapter: this, data: result, fields, globalSlug: slug, operation: 'read' })
|
||||
transform({ adapter: this, data: result, fields, globalSlug, operation: 'read' })
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getGlobal } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
@@ -23,12 +24,11 @@ export async function updateGlobalVersion<T extends TypeWithID>(
|
||||
where,
|
||||
}: UpdateGlobalVersionArgs<T>,
|
||||
) {
|
||||
const VersionModel = this.versions[globalSlug]
|
||||
const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug, versions: true })
|
||||
const whereToUse = where || { id: { equals: id } }
|
||||
|
||||
const currentGlobal = this.payload.config.globals.find((global) => global.slug === globalSlug)
|
||||
const fields = buildVersionGlobalFields(this.payload.config, currentGlobal)
|
||||
const flattenedFields = buildVersionGlobalFields(this.payload.config, currentGlobal, true)
|
||||
const fields = buildVersionGlobalFields(this.payload.config, globalConfig)
|
||||
const flattenedFields = buildVersionGlobalFields(this.payload.config, globalConfig, true)
|
||||
const options: MongooseUpdateQueryOptions = {
|
||||
...optionsArgs,
|
||||
lean: true,
|
||||
@@ -51,11 +51,11 @@ export async function updateGlobalVersion<T extends TypeWithID>(
|
||||
transform({ adapter: this, data: versionData, fields, operation: 'write' })
|
||||
|
||||
if (returning === false) {
|
||||
await VersionModel.updateOne(query, versionData, options)
|
||||
await Model.updateOne(query, versionData, options)
|
||||
return null
|
||||
}
|
||||
|
||||
const doc = await VersionModel.findOneAndUpdate(query, versionData, options)
|
||||
const doc = await Model.findOneAndUpdate(query, versionData, options)
|
||||
|
||||
if (!doc) {
|
||||
return null
|
||||
|
||||
@@ -5,16 +5,26 @@ import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { handleError } from './utilities/handleError.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const updateMany: UpdateMany = async function updateMany(
|
||||
this: MongooseAdapter,
|
||||
{ collection, data, locale, options: optionsArgs = {}, req, returning, select, where },
|
||||
{
|
||||
collection: collectionSlug,
|
||||
data,
|
||||
limit,
|
||||
locale,
|
||||
options: optionsArgs = {},
|
||||
req,
|
||||
returning,
|
||||
select,
|
||||
where,
|
||||
},
|
||||
) {
|
||||
const Model = this.collections[collection]
|
||||
const fields = this.payload.collections[collection].config.fields
|
||||
const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug })
|
||||
|
||||
const options: MongooseUpdateQueryOptions = {
|
||||
...optionsArgs,
|
||||
@@ -22,26 +32,39 @@ export const updateMany: UpdateMany = async function updateMany(
|
||||
new: true,
|
||||
projection: buildProjectionFromSelect({
|
||||
adapter: this,
|
||||
fields: this.payload.collections[collection].config.flattenedFields,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
select,
|
||||
}),
|
||||
session: await getSession(this, req),
|
||||
}
|
||||
|
||||
const query = await buildQuery({
|
||||
let query = await buildQuery({
|
||||
adapter: this,
|
||||
collectionSlug: collection,
|
||||
fields: this.payload.collections[collection].config.flattenedFields,
|
||||
collectionSlug,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
locale,
|
||||
where,
|
||||
})
|
||||
|
||||
transform({ adapter: this, data, fields, operation: 'write' })
|
||||
transform({ adapter: this, data, fields: collectionConfig.fields, operation: 'write' })
|
||||
|
||||
try {
|
||||
if (typeof limit === 'number' && limit > 0) {
|
||||
const documentsToUpdate = await Model.find(
|
||||
query,
|
||||
{},
|
||||
{ ...options, limit, projection: { _id: 1 } },
|
||||
)
|
||||
if (documentsToUpdate.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
query = { _id: { $in: documentsToUpdate.map((doc) => doc._id) } }
|
||||
}
|
||||
|
||||
await Model.updateMany(query, data, options)
|
||||
} catch (error) {
|
||||
handleError({ collection, error, req })
|
||||
handleError({ collection: collectionSlug, error, req })
|
||||
}
|
||||
|
||||
if (returning === false) {
|
||||
@@ -53,7 +76,7 @@ export const updateMany: UpdateMany = async function updateMany(
|
||||
transform({
|
||||
adapter: this,
|
||||
data: result,
|
||||
fields,
|
||||
fields: collectionConfig.fields,
|
||||
operation: 'read',
|
||||
})
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { handleError } from './utilities/handleError.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
@@ -13,26 +14,27 @@ export const updateOne: UpdateOne = async function updateOne(
|
||||
this: MongooseAdapter,
|
||||
{
|
||||
id,
|
||||
collection,
|
||||
collection: collectionSlug,
|
||||
data,
|
||||
locale,
|
||||
options: optionsArgs = {},
|
||||
req,
|
||||
returning,
|
||||
select,
|
||||
where: whereArg,
|
||||
where: whereArg = {},
|
||||
},
|
||||
) {
|
||||
const { collectionConfig, Model } = getCollection({ adapter: this, collectionSlug })
|
||||
const where = id ? { id: { equals: id } } : whereArg
|
||||
const Model = this.collections[collection]
|
||||
const fields = this.payload.collections[collection].config.fields
|
||||
const fields = collectionConfig.fields
|
||||
|
||||
const options: MongooseUpdateQueryOptions = {
|
||||
...optionsArgs,
|
||||
lean: true,
|
||||
new: true,
|
||||
projection: buildProjectionFromSelect({
|
||||
adapter: this,
|
||||
fields: this.payload.collections[collection].config.flattenedFields,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
select,
|
||||
}),
|
||||
session: await getSession(this, req),
|
||||
@@ -40,8 +42,8 @@ export const updateOne: UpdateOne = async function updateOne(
|
||||
|
||||
const query = await buildQuery({
|
||||
adapter: this,
|
||||
collectionSlug: collection,
|
||||
fields: this.payload.collections[collection].config.flattenedFields,
|
||||
collectionSlug,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
locale,
|
||||
where,
|
||||
})
|
||||
@@ -58,7 +60,7 @@ export const updateOne: UpdateOne = async function updateOne(
|
||||
result = await Model.findOneAndUpdate(query, data, options)
|
||||
}
|
||||
} catch (error) {
|
||||
handleError({ collection, error, req })
|
||||
handleError({ collection: collectionSlug, error, req })
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
|
||||
@@ -6,25 +6,34 @@ import type { MongooseAdapter } from './index.js'
|
||||
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { buildProjectionFromSelect } from './utilities/buildProjectionFromSelect.js'
|
||||
import { getCollection } from './utilities/getEntity.js'
|
||||
import { getSession } from './utilities/getSession.js'
|
||||
import { transform } from './utilities/transform.js'
|
||||
|
||||
export const updateVersion: UpdateVersion = async function updateVersion(
|
||||
this: MongooseAdapter,
|
||||
{ id, collection, locale, options: optionsArgs = {}, req, returning, select, versionData, where },
|
||||
{
|
||||
id,
|
||||
collection: collectionSlug,
|
||||
locale,
|
||||
options: optionsArgs = {},
|
||||
req,
|
||||
returning,
|
||||
select,
|
||||
versionData,
|
||||
where,
|
||||
},
|
||||
) {
|
||||
const VersionModel = this.versions[collection]
|
||||
const whereToUse = where || { id: { equals: id } }
|
||||
const fields = buildVersionCollectionFields(
|
||||
this.payload.config,
|
||||
this.payload.collections[collection].config,
|
||||
)
|
||||
const { collectionConfig, Model } = getCollection({
|
||||
adapter: this,
|
||||
collectionSlug,
|
||||
versions: true,
|
||||
})
|
||||
|
||||
const flattenedFields = buildVersionCollectionFields(
|
||||
this.payload.config,
|
||||
this.payload.collections[collection].config,
|
||||
true,
|
||||
)
|
||||
const whereToUse = where || { id: { equals: id } }
|
||||
const fields = buildVersionCollectionFields(this.payload.config, collectionConfig)
|
||||
|
||||
const flattenedFields = buildVersionCollectionFields(this.payload.config, collectionConfig, true)
|
||||
|
||||
const options: MongooseUpdateQueryOptions = {
|
||||
...optionsArgs,
|
||||
@@ -48,11 +57,11 @@ export const updateVersion: UpdateVersion = async function updateVersion(
|
||||
transform({ adapter: this, data: versionData, fields, operation: 'write' })
|
||||
|
||||
if (returning === false) {
|
||||
await VersionModel.updateOne(query, versionData, options)
|
||||
await Model.updateOne(query, versionData, options)
|
||||
return null
|
||||
}
|
||||
|
||||
const doc = await VersionModel.findOneAndUpdate(query, versionData, options)
|
||||
const doc = await Model.findOneAndUpdate(query, versionData, options)
|
||||
|
||||
if (!doc) {
|
||||
return null
|
||||
|
||||
@@ -82,16 +82,18 @@ export const aggregatePaginate = async ({
|
||||
const totalPages =
|
||||
pagination !== false && typeof limit === 'number' && limit !== 0 ? Math.ceil(count / limit) : 1
|
||||
|
||||
const hasPrevPage = pagination !== false && page > 1
|
||||
const hasNextPage = pagination !== false && totalPages > page
|
||||
const hasPrevPage = typeof page === 'number' && pagination !== false && page > 1
|
||||
const hasNextPage = typeof page === 'number' && pagination !== false && totalPages > page
|
||||
const pagingCounter =
|
||||
pagination !== false && typeof limit === 'number' ? (page - 1) * limit + 1 : 1
|
||||
typeof page === 'number' && pagination !== false && typeof limit === 'number'
|
||||
? (page - 1) * limit + 1
|
||||
: 1
|
||||
|
||||
const result: PaginatedDocs = {
|
||||
docs,
|
||||
hasNextPage,
|
||||
hasPrevPage,
|
||||
limit,
|
||||
limit: limit ?? 0,
|
||||
nextPage: hasNextPage ? page + 1 : null,
|
||||
page,
|
||||
pagingCounter,
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
import type { PipelineStage } from 'mongoose'
|
||||
import type {
|
||||
CollectionSlug,
|
||||
FlattenedField,
|
||||
JoinQuery,
|
||||
SanitizedCollectionConfig,
|
||||
Where,
|
||||
} from 'payload'
|
||||
|
||||
import {
|
||||
APIError,
|
||||
type CollectionSlug,
|
||||
type FlattenedField,
|
||||
type JoinQuery,
|
||||
type SanitizedCollectionConfig,
|
||||
} from 'payload'
|
||||
import { fieldShouldBeLocalized } from 'payload/shared'
|
||||
|
||||
import type { MongooseAdapter } from '../index.js'
|
||||
|
||||
import { buildQuery } from '../queries/buildQuery.js'
|
||||
import { buildSortParam } from '../queries/buildSortParam.js'
|
||||
import { getCollection } from './getEntity.js'
|
||||
|
||||
type BuildJoinAggregationArgs = {
|
||||
adapter: MongooseAdapter
|
||||
collection: CollectionSlug
|
||||
collectionConfig: SanitizedCollectionConfig
|
||||
joins: JoinQuery
|
||||
locale: string
|
||||
joins?: JoinQuery
|
||||
locale?: string
|
||||
projection?: Record<string, true>
|
||||
// the where clause for the top collection
|
||||
query?: Where
|
||||
query?: Record<string, unknown>
|
||||
/** whether the query is from drafts */
|
||||
versions?: boolean
|
||||
}
|
||||
@@ -44,9 +45,18 @@ export const buildJoinAggregation = async ({
|
||||
return
|
||||
}
|
||||
|
||||
const joinConfig = adapter.payload.collections[collection].config.joins
|
||||
const joinConfig = adapter.payload.collections[collection]?.config?.joins
|
||||
|
||||
if (!joinConfig) {
|
||||
throw new APIError(`Could not retrieve sanitized join config for ${collection}.`)
|
||||
}
|
||||
|
||||
const aggregate: PipelineStage[] = []
|
||||
const polymorphicJoinsConfig = adapter.payload.collections[collection].config.polymorphicJoins
|
||||
const polymorphicJoinsConfig = adapter.payload.collections[collection]?.config?.polymorphicJoins
|
||||
|
||||
if (!polymorphicJoinsConfig) {
|
||||
throw new APIError(`Could not retrieve sanitized polymorphic joins config for ${collection}.`)
|
||||
}
|
||||
|
||||
for (const join of polymorphicJoinsConfig) {
|
||||
if (projection && !projection[join.joinPath]) {
|
||||
@@ -62,12 +72,14 @@ export const buildJoinAggregation = async ({
|
||||
limit: limitJoin = join.field.defaultLimit ?? 10,
|
||||
page,
|
||||
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
|
||||
where: whereJoin,
|
||||
where: whereJoin = {},
|
||||
} = joins?.[join.joinPath] || {}
|
||||
|
||||
const aggregatedFields: FlattenedField[] = []
|
||||
for (const collectionSlug of join.field.collection) {
|
||||
for (const field of adapter.payload.collections[collectionSlug].config.flattenedFields) {
|
||||
const { collectionConfig } = getCollection({ adapter, collectionSlug })
|
||||
|
||||
for (const field of collectionConfig.flattenedFields) {
|
||||
if (!aggregatedFields.some((eachField) => eachField.name === field.name)) {
|
||||
aggregatedFields.push(field)
|
||||
}
|
||||
@@ -89,7 +101,7 @@ export const buildJoinAggregation = async ({
|
||||
where: whereJoin,
|
||||
})
|
||||
|
||||
const sortProperty = Object.keys(sort)[0]
|
||||
const sortProperty = Object.keys(sort)[0]! // assert because buildSortParam always returns at least 1 key.
|
||||
const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1
|
||||
|
||||
const projectSort = sortProperty !== '_id' && sortProperty !== 'relationTo'
|
||||
@@ -124,10 +136,12 @@ export const buildJoinAggregation = async ({
|
||||
},
|
||||
]
|
||||
|
||||
const { Model: JoinModel } = getCollection({ adapter, collectionSlug })
|
||||
|
||||
aggregate.push({
|
||||
$lookup: {
|
||||
as: alias,
|
||||
from: adapter.collections[collectionSlug].collection.name,
|
||||
from: JoinModel.collection.name,
|
||||
let: {
|
||||
root_id_: '$_id',
|
||||
},
|
||||
@@ -159,7 +173,7 @@ export const buildJoinAggregation = async ({
|
||||
aggregate.push({
|
||||
$lookup: {
|
||||
as: `${as}.totalDocs.${alias}`,
|
||||
from: adapter.collections[collectionSlug].collection.name,
|
||||
from: JoinModel.collection.name,
|
||||
let: {
|
||||
root_id_: '$_id',
|
||||
},
|
||||
@@ -232,7 +246,13 @@ export const buildJoinAggregation = async ({
|
||||
}
|
||||
|
||||
for (const slug of Object.keys(joinConfig)) {
|
||||
for (const join of joinConfig[slug]) {
|
||||
const joinsList = joinConfig[slug]
|
||||
|
||||
if (!joinsList) {
|
||||
throw new APIError(`Failed to retrieve array of joins for ${slug} in collectio ${collection}`)
|
||||
}
|
||||
|
||||
for (const join of joinsList) {
|
||||
if (projection && !projection[join.joinPath]) {
|
||||
continue
|
||||
}
|
||||
@@ -241,31 +261,34 @@ export const buildJoinAggregation = async ({
|
||||
continue
|
||||
}
|
||||
|
||||
const { collectionConfig, Model: JoinModel } = getCollection({
|
||||
adapter,
|
||||
collectionSlug: join.field.collection as string,
|
||||
})
|
||||
|
||||
const {
|
||||
count,
|
||||
limit: limitJoin = join.field.defaultLimit ?? 10,
|
||||
page,
|
||||
sort: sortJoin = join.field.defaultSort || collectionConfig.defaultSort,
|
||||
where: whereJoin,
|
||||
where: whereJoin = {},
|
||||
} = joins?.[join.joinPath] || {}
|
||||
|
||||
if (Array.isArray(join.field.collection)) {
|
||||
throw new Error('Unreachable')
|
||||
}
|
||||
|
||||
const joinModel = adapter.collections[join.field.collection]
|
||||
|
||||
const sort = buildSortParam({
|
||||
config: adapter.payload.config,
|
||||
fields: adapter.payload.collections[slug].config.flattenedFields,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
locale,
|
||||
sort: sortJoin,
|
||||
timestamps: true,
|
||||
})
|
||||
const sortProperty = Object.keys(sort)[0]
|
||||
const sortProperty = Object.keys(sort)[0]!
|
||||
const sortDirection = sort[sortProperty] === 'asc' ? 1 : -1
|
||||
|
||||
const $match = await joinModel.buildQuery({
|
||||
const $match = await JoinModel.buildQuery({
|
||||
locale,
|
||||
payload: adapter.payload,
|
||||
where: whereJoin,
|
||||
@@ -301,7 +324,7 @@ export const buildJoinAggregation = async ({
|
||||
$lookup: {
|
||||
as: `${as}.totalDocs`,
|
||||
foreignField,
|
||||
from: adapter.collections[slug].collection.name,
|
||||
from: JoinModel.collection.name,
|
||||
localField: versions ? 'parent' : '_id',
|
||||
pipeline: [
|
||||
{
|
||||
@@ -329,7 +352,7 @@ export const buildJoinAggregation = async ({
|
||||
$lookup: {
|
||||
as: `${as}.docs`,
|
||||
foreignField: `${join.field.on}${code}${polymorphicSuffix}`,
|
||||
from: adapter.collections[slug].collection.name,
|
||||
from: JoinModel.collection.name,
|
||||
localField: versions ? 'parent' : '_id',
|
||||
pipeline,
|
||||
},
|
||||
@@ -390,7 +413,7 @@ export const buildJoinAggregation = async ({
|
||||
$lookup: {
|
||||
as: `${as}.docs`,
|
||||
foreignField,
|
||||
from: adapter.collections[slug].collection.name,
|
||||
from: JoinModel.collection.name,
|
||||
localField: versions ? 'parent' : '_id',
|
||||
pipeline,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { FieldAffectingData, FlattenedField, SelectMode, SelectType } from 'payload'
|
||||
import type {
|
||||
FieldAffectingData,
|
||||
FlattenedField,
|
||||
SelectIncludeType,
|
||||
SelectMode,
|
||||
SelectType,
|
||||
} from 'payload'
|
||||
|
||||
import {
|
||||
deepCopyObjectSimple,
|
||||
@@ -107,7 +113,7 @@ const traverseFields = ({
|
||||
const fieldSelect = select[field.name] as SelectType
|
||||
|
||||
if (field.type === 'array' && selectMode === 'include') {
|
||||
fieldSelect['id'] = true
|
||||
fieldSelect.id = true
|
||||
}
|
||||
|
||||
traverseFields({
|
||||
@@ -128,6 +134,11 @@ const traverseFields = ({
|
||||
|
||||
for (const _block of field.blockReferences ?? field.blocks) {
|
||||
const block = typeof _block === 'string' ? adapter.payload.blocks[_block] : _block
|
||||
|
||||
if (!block) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
(selectMode === 'include' && blocksSelect[block.slug] === true) ||
|
||||
(selectMode === 'exclude' && typeof blocksSelect[block.slug] === 'undefined')
|
||||
@@ -155,9 +166,10 @@ const traverseFields = ({
|
||||
blocksSelect[block.slug] = {}
|
||||
}
|
||||
|
||||
if (blockSelectMode === 'include') {
|
||||
blocksSelect[block.slug]['id'] = true
|
||||
blocksSelect[block.slug]['blockType'] = true
|
||||
if (blockSelectMode === 'include' && typeof blocksSelect[block.slug] === 'object') {
|
||||
const blockSelect = blocksSelect[block.slug] as SelectIncludeType
|
||||
blockSelect.id = true
|
||||
blockSelect.blockType = true
|
||||
}
|
||||
|
||||
traverseFields({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DBIdentifierName } from 'payload'
|
||||
import { APIError, type DBIdentifierName } from 'payload'
|
||||
|
||||
type Args = {
|
||||
config: {
|
||||
@@ -22,7 +22,7 @@ export const getDBName = ({
|
||||
target = 'dbName',
|
||||
versions = false,
|
||||
}: Args): string => {
|
||||
let result: string
|
||||
let result: null | string = null
|
||||
let custom = config[target]
|
||||
|
||||
if (!custom && target === 'enumName') {
|
||||
@@ -32,12 +32,16 @@ export const getDBName = ({
|
||||
if (custom) {
|
||||
result = typeof custom === 'function' ? custom({}) : custom
|
||||
} else {
|
||||
result = name ?? slug
|
||||
result = name ?? slug ?? null
|
||||
}
|
||||
|
||||
if (versions) {
|
||||
result = `_${result}_versions`
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw new APIError(`Assertion for DB name of ${name} ${slug} was failed.`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
91
packages/db-mongodb/src/utilities/getEntity.ts
Normal file
91
packages/db-mongodb/src/utilities/getEntity.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { Collection, SanitizedCollectionConfig, SanitizedGlobalConfig } from 'payload'
|
||||
|
||||
import { APIError } from 'payload'
|
||||
|
||||
import type { MongooseAdapter } from '../index.js'
|
||||
import type { CollectionModel, GlobalModel } from '../types.js'
|
||||
|
||||
export const getCollection = ({
|
||||
adapter,
|
||||
collectionSlug,
|
||||
versions = false,
|
||||
}: {
|
||||
adapter: MongooseAdapter
|
||||
collectionSlug: string
|
||||
versions?: boolean
|
||||
}): {
|
||||
collectionConfig: SanitizedCollectionConfig
|
||||
customIDType: Collection['customIDType']
|
||||
|
||||
Model: CollectionModel
|
||||
} => {
|
||||
const collection = adapter.payload.collections[collectionSlug]
|
||||
|
||||
if (!collection) {
|
||||
throw new APIError(
|
||||
`ERROR: Failed to retrieve collection with the slug "${collectionSlug}". Does not exist.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (versions) {
|
||||
const Model = adapter.versions[collectionSlug]
|
||||
|
||||
if (!Model) {
|
||||
throw new APIError(
|
||||
`ERROR: Failed to retrieve collection version model with the slug "${collectionSlug}". Does not exist.`,
|
||||
)
|
||||
}
|
||||
|
||||
return { collectionConfig: collection.config, customIDType: collection.customIDType, Model }
|
||||
}
|
||||
|
||||
const Model = adapter.collections[collectionSlug]
|
||||
|
||||
if (!Model) {
|
||||
throw new APIError(
|
||||
`ERROR: Failed to retrieve collection model with the slug "${collectionSlug}". Does not exist.`,
|
||||
)
|
||||
}
|
||||
|
||||
return { collectionConfig: collection.config, customIDType: collection.customIDType, Model }
|
||||
}
|
||||
|
||||
type BaseGetGlobalArgs = {
|
||||
adapter: MongooseAdapter
|
||||
globalSlug: string
|
||||
}
|
||||
|
||||
interface GetGlobal {
|
||||
(args: { versions?: false | undefined } & BaseGetGlobalArgs): {
|
||||
globalConfig: SanitizedGlobalConfig
|
||||
Model: GlobalModel
|
||||
}
|
||||
(args: { versions?: true } & BaseGetGlobalArgs): {
|
||||
globalConfig: SanitizedGlobalConfig
|
||||
Model: CollectionModel
|
||||
}
|
||||
}
|
||||
|
||||
export const getGlobal: GetGlobal = ({ adapter, globalSlug, versions = false }) => {
|
||||
const globalConfig = adapter.payload.config.globals.find((each) => each.slug === globalSlug)
|
||||
|
||||
if (!globalConfig) {
|
||||
throw new APIError(
|
||||
`ERROR: Failed to retrieve global with the slug "${globalSlug}". Does not exist.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (versions) {
|
||||
const Model = adapter.versions[globalSlug]
|
||||
|
||||
if (!Model) {
|
||||
throw new APIError(
|
||||
`ERROR: Failed to retrieve global version model with the slug "${globalSlug}". Does not exist.`,
|
||||
)
|
||||
}
|
||||
|
||||
return { globalConfig, Model }
|
||||
}
|
||||
|
||||
return { globalConfig, Model: adapter.globals } as any
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export const handleError = ({
|
||||
req,
|
||||
}: {
|
||||
collection?: string
|
||||
error: Error
|
||||
error: unknown
|
||||
global?: string
|
||||
req?: Partial<PayloadRequest>
|
||||
}) => {
|
||||
@@ -18,14 +18,20 @@ export const handleError = ({
|
||||
}
|
||||
|
||||
// Handle uniqueness error from MongoDB
|
||||
if ('code' in error && error.code === 11000 && 'keyValue' in error && error.keyValue) {
|
||||
if (
|
||||
'code' in error &&
|
||||
error.code === 11000 &&
|
||||
'keyValue' in error &&
|
||||
error.keyValue &&
|
||||
typeof error.keyValue === 'object'
|
||||
) {
|
||||
throw new ValidationError(
|
||||
{
|
||||
collection,
|
||||
errors: [
|
||||
{
|
||||
message: req?.t ? req.t('error:valueMustBeUnique') : 'Value must be unique',
|
||||
path: Object.keys(error.keyValue)[0],
|
||||
path: Object.keys(error.keyValue)[0] ?? '',
|
||||
},
|
||||
],
|
||||
global,
|
||||
@@ -34,5 +40,6 @@ export const handleError = ({
|
||||
)
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/only-throw-error
|
||||
throw error
|
||||
}
|
||||
|
||||
@@ -234,12 +234,12 @@ export const transform = ({
|
||||
fields,
|
||||
globalSlug,
|
||||
operation,
|
||||
parentIsLocalized,
|
||||
parentIsLocalized = false,
|
||||
validateRelationships = true,
|
||||
}: Args) => {
|
||||
if (Array.isArray(data)) {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
transform({ adapter, data: data[i], fields, globalSlug, operation, validateRelationships })
|
||||
for (const item of data) {
|
||||
transform({ adapter, data: item, fields, globalSlug, operation, validateRelationships })
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -262,14 +262,16 @@ export const transform = ({
|
||||
data.globalType = globalSlug
|
||||
}
|
||||
|
||||
const sanitize: TraverseFieldsCallback = ({ field, ref }) => {
|
||||
if (!ref || typeof ref !== 'object') {
|
||||
const sanitize: TraverseFieldsCallback = ({ field, ref: incomingRef }) => {
|
||||
if (!incomingRef || typeof incomingRef !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (field.type === 'date' && operation === 'read' && ref[field.name]) {
|
||||
const ref = incomingRef as Record<string, unknown>
|
||||
|
||||
if (field.type === 'date' && operation === 'read' && field.name in ref && ref[field.name]) {
|
||||
if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
|
||||
const fieldRef = ref[field.name]
|
||||
const fieldRef = ref[field.name] as Record<string, unknown>
|
||||
if (!fieldRef || typeof fieldRef !== 'object') {
|
||||
return
|
||||
}
|
||||
@@ -284,7 +286,7 @@ export const transform = ({
|
||||
} else {
|
||||
sanitizeDate({
|
||||
field,
|
||||
ref: ref as Record<string, unknown>,
|
||||
ref,
|
||||
value: ref[field.name],
|
||||
})
|
||||
}
|
||||
@@ -302,13 +304,13 @@ export const transform = ({
|
||||
// handle localized relationships
|
||||
if (config.localization && fieldShouldBeLocalized({ field, parentIsLocalized })) {
|
||||
const locales = config.localization.locales
|
||||
const fieldRef = ref[field.name]
|
||||
const fieldRef = ref[field.name] as Record<string, unknown>
|
||||
if (typeof fieldRef !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
for (const { code } of locales) {
|
||||
const value = ref[field.name][code]
|
||||
const value = fieldRef[code]
|
||||
if (value) {
|
||||
sanitizeRelationship({
|
||||
config,
|
||||
@@ -328,7 +330,7 @@ export const transform = ({
|
||||
field,
|
||||
locale: undefined,
|
||||
operation,
|
||||
ref: ref as Record<string, unknown>,
|
||||
ref,
|
||||
validateRelationships,
|
||||
value: ref[field.name],
|
||||
})
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
/* TODO: remove the following lines */
|
||||
"strict": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
},
|
||||
"references": [{ "path": "../payload" }, { "path": "../translations" }]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-postgres",
|
||||
"version": "3.25.0",
|
||||
"version": "3.27.0",
|
||||
"description": "The officially supported Postgres database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-sqlite",
|
||||
"version": "3.25.0",
|
||||
"version": "3.27.0",
|
||||
"description": "The officially supported SQLite database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user