Compare commits
52 Commits
feat/adds-
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9d0d1ffcc | ||
|
|
9031f3bf23 | ||
|
|
df91321f4a | ||
|
|
11755089f8 | ||
|
|
a8b6983ab5 | ||
|
|
f2d4004237 | ||
|
|
8a489410ad | ||
|
|
095e7d904f | ||
|
|
c48b57fdbf | ||
|
|
b26a73be4a | ||
|
|
3114b89d4c | ||
|
|
227a20e94b | ||
|
|
a22f27de1c | ||
|
|
e7124f6176 | ||
|
|
183f313387 | ||
|
|
b1fa76e397 | ||
|
|
08942494e3 | ||
|
|
da8bf69054 | ||
|
|
26d9daeccf | ||
|
|
fc5944840e | ||
|
|
9e04dbb1ca | ||
|
|
72954ce9f2 | ||
|
|
e50220374e | ||
|
|
61ee8fadca | ||
|
|
8d84352ee9 | ||
|
|
4beb27b9ad | ||
|
|
c5c8c13057 | ||
|
|
a888d5cc53 | ||
|
|
72349245ca | ||
|
|
4fde0f23ce | ||
|
|
aff2ce1b9b | ||
|
|
5c94d2dc71 | ||
|
|
b1aac19668 | ||
|
|
d093bb1f00 | ||
|
|
2e9ba10fb5 | ||
|
|
8518141a5e | ||
|
|
6d6c9ebc56 | ||
|
|
7cd4a8a602 | ||
|
|
bc802846c5 | ||
|
|
e8f6cb5ed1 | ||
|
|
23bd67515c | ||
|
|
e29d1d98d4 | ||
|
|
4ac428d250 | ||
|
|
75385de01f | ||
|
|
f63dc2a10c | ||
|
|
4a712b3483 | ||
|
|
fa7d209cc9 | ||
|
|
bccf6ab16f | ||
|
|
14322a71bb | ||
|
|
7e81d30808 | ||
|
|
a83ed5ebb5 | ||
|
|
8f85da8931 |
7
.github/workflows/audit-dependencies.sh
vendored
7
.github/workflows/audit-dependencies.sh
vendored
@@ -13,7 +13,8 @@ echo "${audit_json}" | jq --arg severity "${severity}" '
|
||||
{
|
||||
package: .value.module_name,
|
||||
vulnerable: .value.vulnerable_versions,
|
||||
fixed_in: .value.patched_versions
|
||||
fixed_in: .value.patched_versions,
|
||||
findings: .value.findings
|
||||
}
|
||||
)
|
||||
' >$output_file
|
||||
@@ -23,7 +24,11 @@ audit_length=$(jq 'length' $output_file)
|
||||
if [[ "${audit_length}" -gt "0" ]]; then
|
||||
echo "Actionable vulnerabilities found in the following packages:"
|
||||
jq -r '.[] | "\u001b[1m\(.package)\u001b[0m vulnerable in \u001b[31m\(.vulnerable)\u001b[0m fixed in \u001b[32m\(.fixed_in)\u001b[0m"' $output_file | while read -r line; do echo -e "$line"; done
|
||||
echo ""
|
||||
echo "Output written to ${output_file}"
|
||||
cat $output_file
|
||||
echo ""
|
||||
echo "This script can be rerun with: './.github/workflows/audit-dependencies.sh $severity'"
|
||||
exit 1
|
||||
else
|
||||
echo "No actionable vulnerabilities"
|
||||
|
||||
2
.github/workflows/audit-dependencies.yml
vendored
2
.github/workflows/audit-dependencies.yml
vendored
@@ -46,7 +46,7 @@ jobs:
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": "🚨 Actionable vulnerabilities found: <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Details>"
|
||||
"text": "🚨 Actionable vulnerabilities found: <https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}|View Script Run Details>"
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
4
.github/workflows/main.yml
vendored
4
.github/workflows/main.yml
vendored
@@ -284,6 +284,7 @@ jobs:
|
||||
- fields__collections__Text
|
||||
- fields__collections__UI
|
||||
- fields__collections__Upload
|
||||
- group-by
|
||||
- folders
|
||||
- hooks
|
||||
- lexical__collections__Lexical__e2e__main
|
||||
@@ -303,6 +304,7 @@ jobs:
|
||||
- plugin-nested-docs
|
||||
- plugin-seo
|
||||
- sort
|
||||
- trash
|
||||
- versions
|
||||
- uploads
|
||||
env:
|
||||
@@ -419,6 +421,7 @@ jobs:
|
||||
- fields__collections__Text
|
||||
- fields__collections__UI
|
||||
- fields__collections__Upload
|
||||
- group-by
|
||||
- folders
|
||||
- hooks
|
||||
- lexical__collections__Lexical__e2e__main
|
||||
@@ -438,6 +441,7 @@ jobs:
|
||||
- plugin-nested-docs
|
||||
- plugin-seo
|
||||
- sort
|
||||
- trash
|
||||
- versions
|
||||
- uploads
|
||||
env:
|
||||
|
||||
7
.vscode/launch.json
vendored
7
.vscode/launch.json
vendored
@@ -139,6 +139,13 @@
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"command": "pnpm tsx --no-deprecation test/dev.ts trash",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"name": "Run Dev Trash",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
},
|
||||
{
|
||||
"command": "pnpm tsx --no-deprecation test/dev.ts uploads",
|
||||
"cwd": "${workspaceFolder}",
|
||||
|
||||
@@ -77,7 +77,7 @@ All auto-generated files will contain the following comments at the top of each
|
||||
|
||||
## Admin Options
|
||||
|
||||
All options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property:
|
||||
All root-level options for the Admin Panel are defined in your [Payload Config](../configuration/overview) under the `admin` property:
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
|
||||
@@ -60,32 +60,33 @@ export const Posts: CollectionConfig = {
|
||||
|
||||
The following options are available:
|
||||
|
||||
| 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) |
|
||||
| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
|
||||
| `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). |
|
||||
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
|
||||
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
|
||||
| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
|
||||
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
|
||||
| `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. |
|
||||
| `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. [More details](../database/indexes#compound-indexes). |
|
||||
| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks |
|
||||
| `disableBulkEdit` | Disable the bulk edit operation for the collection in the admin panel and the REST API |
|
||||
| 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) |
|
||||
| `disableDuplicate` | When true, do not show the "Duplicate" button while editing documents within this Collection and prevent `duplicate` from all APIs. |
|
||||
| `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). |
|
||||
| `graphQL` | Manage GraphQL-related properties for this collection. [More](#graphql) |
|
||||
| `hooks` | Entry point for Hooks. [More details](../hooks/overview#collection-hooks). |
|
||||
| `orderable` | If true, enables custom ordering for the collection, and documents can be reordered via drag and drop. Uses [fractional indexing](https://observablehq.com/@dgreensp/implementing-fractional-indexing) for efficient reordering. |
|
||||
| `labels` | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||
| `enableQueryPresets` | Enable query presets for this Collection. [More details](../query-presets/overview). |
|
||||
| `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. |
|
||||
| `timestamps` | Set to false to disable documents' automatically generated `createdAt` and `updatedAt` timestamps. |
|
||||
| `trash` | A boolean to enable soft deletes for this collection. Defaults to `false`. [More details](../trash/overview). |
|
||||
| `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. |
|
||||
| `forceSelect` | Specify which fields should be selected always, regardless of the `select` query which can be useful that the field exists for access control / hooks |
|
||||
| `disableBulkEdit` | Disable the bulk edit operation for the collection in the admin panel and the REST API |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
|
||||
@@ -130,6 +131,7 @@ The following options are available:
|
||||
| `description` | Text to display below the Collection label in the List View to give editors more information. Alternatively, you can use the `admin.components.Description` to render a React component. [More details](#custom-components). |
|
||||
| `defaultColumns` | Array of field names that correspond to which columns to show by default in this Collection's List View. |
|
||||
| `disableCopyToLocale` | Disables the "Copy to Locale" button while editing documents within this Collection. Only applicable when localization is enabled. |
|
||||
| `groupBy` | Beta. Enable grouping by a field in the list view. |
|
||||
| `hideAPIURL` | Hides the "API URL" meta field while editing documents within this Collection. |
|
||||
| `enableRichTextLink` | The [Rich Text](../fields/rich-text) field features a `Link` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
|
||||
| `enableRichTextRelationship` | The [Rich Text](../fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
|
||||
|
||||
@@ -34,20 +34,20 @@ npm i @payloadcms/plugin-csm
|
||||
Then in the `plugins` array of your Payload Config, call the plugin and enable any collections that require Content Source Maps.
|
||||
|
||||
```ts
|
||||
import { buildConfig } from "payload/config"
|
||||
import contentSourceMaps from "@payloadcms/plugin-csm"
|
||||
import { buildConfig } from 'payload/config'
|
||||
import contentSourceMaps from '@payloadcms/plugin-csm'
|
||||
|
||||
const config = buildConfig({
|
||||
collections: [
|
||||
{
|
||||
slug: "pages",
|
||||
slug: 'pages',
|
||||
fields: [
|
||||
{
|
||||
name: 'slug',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'title,'
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
@@ -55,7 +55,7 @@ const config = buildConfig({
|
||||
],
|
||||
plugins: [
|
||||
contentSourceMaps({
|
||||
collections: ["pages"],
|
||||
collections: ['pages'],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@@ -77,7 +77,6 @@ This configuration only queues the Job - it does not execute it immediately. To
|
||||
```ts
|
||||
export default buildConfig({
|
||||
jobs: {
|
||||
scheduler: 'cron',
|
||||
autoRun: [
|
||||
{
|
||||
cron: '* * * * *', // Runs every minute
|
||||
|
||||
@@ -45,13 +45,11 @@ The following options are available:
|
||||
|
||||
| Path | Description |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`url`** \* | String, or function that returns a string, pointing to your front-end application. This value is used as the iframe `src`. [More details](#url). |
|
||||
| **`url`** | String, or function that returns a string, pointing to your front-end application. This value is used as the iframe `src`. [More details](#url). |
|
||||
| **`breakpoints`** | Array of breakpoints to be used as “device sizes” in the preview window. Each item appears as an option in the toolbar. [More details](#breakpoints). |
|
||||
| **`collections`** | Array of collection slugs to enable Live Preview on. |
|
||||
| **`globals`** | Array of global slugs to enable Live Preview on. |
|
||||
|
||||
_\* An asterisk denotes that a property is required._
|
||||
|
||||
### URL
|
||||
|
||||
The `url` property resolves to a string that points to your front-end application. This value is used as the `src` attribute of the iframe rendering your front-end. Once loaded, the Admin Panel will communicate directly with your app through `window.postMessage` events.
|
||||
@@ -88,17 +86,16 @@ const config = buildConfig({
|
||||
// ...
|
||||
livePreview: {
|
||||
// highlight-start
|
||||
url: ({
|
||||
data,
|
||||
collectionConfig,
|
||||
locale
|
||||
}) => `${data.tenant.url}${ // Multi-tenant top-level domain
|
||||
collectionConfig.slug === 'posts' ? `/posts/${data.slug}` : `${data.slug !== 'home' : `/${data.slug}` : ''}`
|
||||
}${locale ? `?locale=${locale?.code}` : ''}`, // Localization query param
|
||||
url: ({ data, collectionConfig, locale }) =>
|
||||
`${data.tenant.url}${
|
||||
collectionConfig.slug === 'posts'
|
||||
? `/posts/${data.slug}`
|
||||
: `${data.slug !== 'home' ? `/${data.slug}` : ''}`
|
||||
}${locale ? `?locale=${locale?.code}` : ''}`, // Localization query param
|
||||
collections: ['pages'],
|
||||
},
|
||||
// highlight-end
|
||||
}
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ export default async function Page() {
|
||||
collection: 'pages',
|
||||
id: '123',
|
||||
draft: true,
|
||||
trash: true, // add this if trash is enabled in your collection and want to preview trashed documents
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Form Builder Plugin
|
||||
label: Form Builder
|
||||
order: 40
|
||||
order: 30
|
||||
desc: Easily build and manage forms from the Admin Panel. Send dynamic, personalized emails and even accept and process payments.
|
||||
keywords: plugins, plugin, form, forms, form builder
|
||||
---
|
||||
|
||||
155
docs/plugins/import-export.mdx
Normal file
155
docs/plugins/import-export.mdx
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
title: Import Export Plugin
|
||||
label: Import Export
|
||||
order: 40
|
||||
desc: Add Import and export functionality to create CSV and JSON data exports
|
||||
keywords: plugins, plugin, import, export, csv, JSON, data, ETL, download
|
||||
---
|
||||
|
||||

|
||||
|
||||
<Banner type="warning">
|
||||
**Note**: This plugin is in **beta** as some aspects of it may change on any
|
||||
minor releases. It is under development and currently only supports exporting
|
||||
of collection data.
|
||||
</Banner>
|
||||
|
||||
This plugin adds features that give admin users the ability to download or create export data as an upload collection and import it back into a project.
|
||||
|
||||
## Core Features
|
||||
|
||||
- Export data as CSV or JSON format via the admin UI
|
||||
- Download the export directly through the browser
|
||||
- Create a file upload of the export data
|
||||
- Use the jobs queue for large exports
|
||||
- (Coming soon) Import collection data
|
||||
|
||||
## Installation
|
||||
|
||||
Install the plugin using any JavaScript package manager like [pnpm](https://pnpm.io), [npm](https://npmjs.com), or [Yarn](https://yarnpkg.com):
|
||||
|
||||
```bash
|
||||
pnpm add @payloadcms/plugin-import-export
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
In the `plugins` array of your [Payload Config](https://payloadcms.com/docs/configuration/overview), call the plugin with [options](#options):
|
||||
|
||||
```ts
|
||||
import { buildConfig } from 'payload'
|
||||
import { importExportPlugin } from '@payloadcms/plugin-import-export'
|
||||
|
||||
const config = buildConfig({
|
||||
collections: [Pages, Media],
|
||||
plugins: [
|
||||
importExportPlugin({
|
||||
collections: ['users', 'pages'],
|
||||
// see below for a list of available options
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
export default config
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `collections` | string[] | Collections to include Import/Export controls in. Defaults to all collections. |
|
||||
| `debug` | boolean | If true, enables debug logging. |
|
||||
| `disableDownload` | boolean | If true, disables the download button in the export preview UI. |
|
||||
| `disableJobsQueue` | boolean | If true, forces the export to run synchronously. |
|
||||
| `disableSave` | boolean | If true, disables the save button in the export preview UI. |
|
||||
| `format` | string | Forces a specific export format (`csv` or `json`), hides the format dropdown, and prevents the user from choosing the export format. |
|
||||
| `overrideExportCollection` | function | Function to override the default export collection; takes the default export collection and allows you to modify and return it. |
|
||||
|
||||
## Field Options
|
||||
|
||||
In addition to the above plugin configuration options, you can granularly set the following field level options using the `custom['plugin-import-export']` properties in any of your collections.
|
||||
|
||||
| Property | Type | Description |
|
||||
| ---------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | boolean | When `true` the field is completely excluded from the import-export plugin. |
|
||||
| `toCSV` | function | Custom function used to modify the outgoing csv data by manipulating the data, siblingData or by returning the desired value. |
|
||||
|
||||
### Customizing the output of CSV data
|
||||
|
||||
To manipulate the data that a field exports you can add `toCSV` custom functions. This allows you to modify the outgoing csv data by manipulating the data, siblingData or by returning the desired value.
|
||||
|
||||
The toCSV function argument is an object with the following properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ------------ | ------- | ----------------------------------------------------------------- |
|
||||
| `columnName` | string | The CSV column name given to the field. |
|
||||
| `doc` | object | The top level document |
|
||||
| `row` | object | The object data that can be manipulated to assign data to the CSV |
|
||||
| `siblingDoc` | object | The document data at the level where it belongs |
|
||||
| `value` | unknown | The data for the field. |
|
||||
|
||||
Example function:
|
||||
|
||||
```ts
|
||||
const pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
fields: [
|
||||
{
|
||||
name: 'author',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
custom: {
|
||||
'plugin-import-export': {
|
||||
toCSV: ({ value, columnName, row }) => {
|
||||
// add both `author_id` and the `author_email` to the csv export
|
||||
if (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
'id' in value &&
|
||||
'email' in value
|
||||
) {
|
||||
row[`${columnName}_id`] = (value as { id: number | string }).id
|
||||
row[`${columnName}_email`] = (value as { email: string }).email
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Exporting Data
|
||||
|
||||
There are four possible ways that the plugin allows for exporting documents, the first two are available in the admin UI from the list view of a collection:
|
||||
|
||||
1. Direct download - Using a `POST` to `/api/exports/download` and streams the response as a file download
|
||||
2. File storage - Goes to the `exports` collection as an uploads enabled collection
|
||||
3. Local API - A create call to the uploads collection: `payload.create({ slug: 'uploads', ...parameters })`
|
||||
4. Jobs Queue - `payload.jobs.queue({ task: 'createCollectionExport', input: parameters })`
|
||||
|
||||
By default, a user can use the Export drawer to create a file download by choosing `Save` or stream a downloadable file directly without persisting it by using the `Download` button. Either option can be disabled to provide the export experience you desire for your use-case.
|
||||
|
||||
The UI for creating exports provides options so that users can be selective about which documents to include and also which columns or fields to include.
|
||||
|
||||
It is necessary to add access control to the uploads collection configuration using the `overrideExportCollection` function if you have enabled this plugin on collections with data that some authenticated users should not have access to.
|
||||
|
||||
<Banner type="warning">
|
||||
**Note**: Users who have read access to the upload collection may be able to
|
||||
download data that is normally not readable due to [access
|
||||
control](../access-control/overview).
|
||||
</Banner>
|
||||
|
||||
The following parameters are used by the export function to handle requests:
|
||||
|
||||
| Property | Type | Description |
|
||||
| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| `format` | text | Either `csv` or `json` to determine the shape of data exported |
|
||||
| `limit` | number | The max number of documents to return |
|
||||
| `sort` | select | The field to use for ordering documents |
|
||||
| `locale` | string | The locale code to query documents or `all` |
|
||||
| `draft` | string | Either `yes` or `no` to return documents with their newest drafts for drafts enabled collections |
|
||||
| `fields` | string[] | Which collection fields are used to create the export, defaults to all |
|
||||
| `collectionSlug` | string | The slug to query against |
|
||||
| `where` | object | The WhereObject used to query documents to export. This is set by making selections or filters from the list view |
|
||||
| `filename` | text | What to call the export being created |
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Multi-Tenant Plugin
|
||||
label: Multi-Tenant
|
||||
order: 40
|
||||
order: 50
|
||||
desc: Scaffolds multi-tenancy for your Payload application
|
||||
keywords: plugins, multi-tenant, multi-tenancy, plugin, payload, cms, seo, indexing, search, search engine
|
||||
---
|
||||
@@ -229,15 +229,15 @@ const config = buildConfig({
|
||||
{
|
||||
slug: 'tenants',
|
||||
admin: {
|
||||
useAsTitle: 'name'
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
fields: [
|
||||
// remember, you own these fields
|
||||
// these are merely suggestions/examples
|
||||
{
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'slug',
|
||||
@@ -248,7 +248,7 @@ const config = buildConfig({
|
||||
name: 'domain',
|
||||
type: 'text',
|
||||
required: true,
|
||||
}
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -258,7 +258,7 @@ const config = buildConfig({
|
||||
pages: {},
|
||||
navigation: {
|
||||
isGlobal: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Nested Docs Plugin
|
||||
label: Nested Docs
|
||||
order: 40
|
||||
order: 60
|
||||
desc: Nested documents in a parent, child, and sibling relationship.
|
||||
keywords: plugins, nested, documents, parent, child, sibling, relationship
|
||||
---
|
||||
|
||||
@@ -55,6 +55,7 @@ Payload maintains a set of Official Plugins that solve for some of the common us
|
||||
- [Sentry](./sentry)
|
||||
- [SEO](./seo)
|
||||
- [Stripe](./stripe)
|
||||
- [Import/Export](./import-export)
|
||||
|
||||
You can also [build your own plugin](./build-your-own) to easily extend Payload's functionality in some other way. Once your plugin is ready, consider [sharing it with the community](#community-plugins).
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Redirects Plugin
|
||||
label: Redirects
|
||||
order: 40
|
||||
order: 70
|
||||
desc: Automatically create redirects for your Payload application
|
||||
keywords: plugins, redirects, redirect, plugin, payload, cms, seo, indexing, search, search engine
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Search Plugin
|
||||
label: Search
|
||||
order: 40
|
||||
order: 80
|
||||
desc: Generates records of your documents that are extremely fast to search on.
|
||||
keywords: plugins, search, search plugin, search engine, search index, search results, search bar, search box, search field, search form, search input
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Sentry Plugin
|
||||
label: Sentry
|
||||
order: 40
|
||||
order: 90
|
||||
desc: Integrate Sentry error tracking into your Payload application
|
||||
keywords: plugins, sentry, error, tracking, monitoring, logging, bug, reporting, performance
|
||||
---
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description: Manage SEO metadata from your Payload admin
|
||||
keywords: plugins, seo, meta, search, engine, ranking, google
|
||||
label: SEO
|
||||
order: 30
|
||||
order: 100
|
||||
title: SEO Plugin
|
||||
---
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: Stripe Plugin
|
||||
label: Stripe
|
||||
order: 40
|
||||
order: 110
|
||||
desc: Easily accept payments with Stripe
|
||||
keywords: plugins, stripe, payments, ecommerce
|
||||
---
|
||||
|
||||
200
docs/trash/overview.mdx
Normal file
200
docs/trash/overview.mdx
Normal file
@@ -0,0 +1,200 @@
|
||||
---
|
||||
title: Trash
|
||||
label: Overview
|
||||
order: 10
|
||||
desc: Enable soft deletes for your collections to mark documents as deleted without permanently removing them.
|
||||
keywords: trash, soft delete, deletedAt, recovery, restore
|
||||
---
|
||||
|
||||
Trash (also known as soft delete) allows documents to be marked as deleted without being permanently removed. When enabled on a collection, deleted documents will receive a `deletedAt` timestamp, making it possible to restore them later, view them in a dedicated Trash view, or permanently delete them.
|
||||
|
||||
Soft delete is a safer way to manage content lifecycle, giving editors a chance to review and recover documents that may have been deleted by mistake.
|
||||
|
||||
<Banner type="warning">
|
||||
**Note:** The Trash feature is currently in beta and may be subject to change
|
||||
in minor version updates.
|
||||
</Banner>
|
||||
|
||||
## Collection Configuration
|
||||
|
||||
To enable soft deleting for a collection, set the `trash` property to `true`:
|
||||
|
||||
```ts
|
||||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
trash: true,
|
||||
fields: [
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
},
|
||||
// other fields...
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
When enabled, Payload automatically injects a deletedAt field into the collection's schema. This timestamp is set when a document is soft-deleted, and cleared when the document is restored.
|
||||
|
||||
## Admin Panel behavior
|
||||
|
||||
Once `trash` is enabled, the Admin Panel provides a dedicated Trash view for each collection:
|
||||
|
||||
- A new route is added at `/collections/:collectionSlug/trash`
|
||||
- The `Trash` view shows all documents that have a `deletedAt` timestamp
|
||||
|
||||
From the Trash view, you can:
|
||||
|
||||
- Use bulk actions to manage trashed documents:
|
||||
|
||||
- **Restore** to clear the `deletedAt` timestamp and return documents to their original state
|
||||
- **Delete** to permanently remove selected documents
|
||||
- **Empty Trash** to select and permanently delete all trashed documents at once
|
||||
|
||||
- Enter each document's **edit view**, just like in the main list view. While in the edit view of a trashed document:
|
||||
- All fields are in a **read-only** state
|
||||
- Standard document actions (e.g., Save, Publish, Restore Version) are hidden and disabled.
|
||||
- The available actions are **Restore** and **Permanently Delete**.
|
||||
- Access to the **API**, **Versions**, and **Preview** views is preserved.
|
||||
|
||||
When deleting a document from the main collection List View, Payload will soft-delete the document by default. A checkbox in the delete confirmation modal allows users to skip the trash and permanently delete instead.
|
||||
|
||||
## API Support
|
||||
|
||||
Soft deletes are fully supported across all Payload APIs: **Local**, **REST**, and **GraphQL**.
|
||||
|
||||
The following operations respect and support the `trash` functionality:
|
||||
|
||||
- `find`
|
||||
- `findByID`
|
||||
- `update`
|
||||
- `updateByID`
|
||||
- `delete`
|
||||
- `deleteByID`
|
||||
- `findVersions`
|
||||
- `findVersionByID`
|
||||
|
||||
### Understanding `trash` Behavior
|
||||
|
||||
Passing `trash: true` to these operations will **include soft-deleted documents** in the query results.
|
||||
|
||||
To return _only_ soft-deleted documents, you must combine `trash: true` with a `where` clause that checks if `deletedAt` exists.
|
||||
|
||||
### Examples
|
||||
|
||||
#### Local API
|
||||
|
||||
Return all documents including trashed:
|
||||
|
||||
```ts
|
||||
const result = await payload.find({
|
||||
collection: 'posts',
|
||||
trash: true,
|
||||
})
|
||||
```
|
||||
|
||||
Return only trashed documents:
|
||||
|
||||
```ts
|
||||
const result = await payload.find({
|
||||
collection: 'posts',
|
||||
trash: true,
|
||||
where: {
|
||||
deletedAt: {
|
||||
exists: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Return only non-trashed documents:
|
||||
|
||||
```ts
|
||||
const result = await payload.find({
|
||||
collection: 'posts',
|
||||
trash: false,
|
||||
})
|
||||
```
|
||||
|
||||
#### REST
|
||||
|
||||
Return **all** documents including trashed:
|
||||
|
||||
```http
|
||||
GET /api/posts?trash=true
|
||||
```
|
||||
|
||||
Return **only trashed** documents:
|
||||
|
||||
```http
|
||||
GET /api/posts?trash=true&where[deletedAt][exists]=true
|
||||
```
|
||||
|
||||
Return only non-trashed documents:
|
||||
|
||||
```http
|
||||
GET /api/posts?trash=false
|
||||
```
|
||||
|
||||
#### GraphQL
|
||||
|
||||
Return all documents including trashed:
|
||||
|
||||
```ts
|
||||
query {
|
||||
Posts(trash: true) {
|
||||
docs {
|
||||
id
|
||||
deletedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Return only trashed documents:
|
||||
|
||||
```ts
|
||||
query {
|
||||
Posts(
|
||||
trash: true
|
||||
where: { deletedAt: { exists: true } }
|
||||
) {
|
||||
docs {
|
||||
id
|
||||
deletedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Return only non-trashed documents:
|
||||
|
||||
```ts
|
||||
query {
|
||||
Posts(trash: false) {
|
||||
docs {
|
||||
id
|
||||
deletedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Access Control
|
||||
|
||||
All trash-related actions (delete, permanent delete) respect the `delete` access control defined in your collection config.
|
||||
|
||||
This means:
|
||||
|
||||
- If a user is denied delete access, they cannot soft delete or permanently delete documents
|
||||
|
||||
## Versions and Trash
|
||||
|
||||
When a document is soft-deleted:
|
||||
|
||||
- It can no longer have a version **restored** until it is first restored from trash
|
||||
- Attempting to restore a version while the document is in trash will result in an error
|
||||
- This ensures consistency between the current document state and its version history
|
||||
|
||||
However, versions are still fully **visible and accessible** from the **edit view** of a trashed document. You can view the full version history, but must restore the document itself before restoring any individual version.
|
||||
@@ -90,33 +90,33 @@ export const Media: CollectionConfig = {
|
||||
|
||||
_An asterisk denotes that an option is required._
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
|
||||
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
|
||||
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
|
||||
| **`constructorOptions`** | An object passed to the the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) |
|
||||
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
|
||||
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
|
||||
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |
|
||||
| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. |
|
||||
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
|
||||
| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. |
|
||||
| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) |
|
||||
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
|
||||
| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. |
|
||||
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
|
||||
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
|
||||
| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) |
|
||||
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
|
||||
| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. |
|
||||
| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) |
|
||||
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
|
||||
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
|
||||
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
|
||||
| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. |
|
||||
| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. |
|
||||
| **`modifyResponseHeaders`** | Accepts an object with existing `headers` and allows you to manipulate the response headers for media files. [More](#modifying-response-headers) |
|
||||
| Option | Description |
|
||||
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`adminThumbnail`** | Set the way that the [Admin Panel](../admin/overview) will display thumbnails for this Collection. [More](#admin-thumbnails) |
|
||||
| **`bulkUpload`** | Allow users to upload in bulk from the list view, default is true |
|
||||
| **`cacheTags`** | Set to `false` to disable the cache tag set in the UI for the admin thumbnail component. Useful for when CDNs don't allow certain cache queries. |
|
||||
| **`constructorOptions`** | An object passed to the the Sharp image library that accepts any Constructor options and applies them to the upload file. [More](https://sharp.pixelplumbing.com/api-constructor/) |
|
||||
| **`crop`** | Set to `false` to disable the cropping tool in the [Admin Panel](../admin/overview). Crop is enabled by default. [More](#crop-and-focal-point-selector) |
|
||||
| **`disableLocalStorage`** | Completely disable uploading files to disk locally. [More](#disabling-local-upload-storage) |
|
||||
| **`displayPreview`** | Enable displaying preview of the uploaded file in Upload fields related to this Collection. Can be locally overridden by `displayPreview` option in Upload field. [More](/docs/fields/upload#config-options). |
|
||||
| **`externalFileHeaderFilter`** | Accepts existing headers and returns the headers after filtering or modifying. If using this option, you should handle the removal of any sensitive cookies (like payload-prefixed cookies) to prevent leaking session information to external services. By default, Payload automatically filters out payload-prefixed cookies when this option is not defined. |
|
||||
| **`filesRequiredOnCreate`** | Mandate file data on creation, default is true. |
|
||||
| **`filenameCompoundIndex`** | Field slugs to use for a compound index instead of the default filename index. |
|
||||
| **`focalPoint`** | Set to `false` to disable the focal point selection tool in the [Admin Panel](../admin/overview). The focal point selector is only available when `imageSizes` or `resizeOptions` are defined. [More](#crop-and-focal-point-selector) |
|
||||
| **`formatOptions`** | An object with `format` and `options` that are used with the Sharp image library to format the upload file. [More](https://sharp.pixelplumbing.com/api-output#toformat) |
|
||||
| **`handlers`** | Array of Request handlers to execute when fetching a file, if a handler returns a Response it will be sent to the client. Otherwise Payload will retrieve and send back the file. |
|
||||
| **`imageSizes`** | If specified, image uploads will be automatically resized in accordance to these image sizes. [More](#image-sizes) |
|
||||
| **`mimeTypes`** | Restrict mimeTypes in the file picker. Array of valid mimetypes or mimetype wildcards [More](#mimetypes) |
|
||||
| **`pasteURL`** | Controls whether files can be uploaded from remote URLs by pasting them into the Upload field. **Enabled by default.** Accepts `false` to disable or an object with an `allowList` of valid remote URLs. [More](#uploading-files-from-remote-urls) |
|
||||
| **`resizeOptions`** | An object passed to the the Sharp image library to resize the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize) |
|
||||
| **`skipSafeFetch`** | Set to an `allowList` to skip the safe fetch check when fetching external files. Set to `true` to skip the safe fetch for all documents in this collection. Defaults to `false`. |
|
||||
| **`allowRestrictedFileTypes`** | Set to `true` to allow restricted file types. If your Collection has defined [mimeTypes](#mimetypes), restricted file verification will be skipped. Defaults to `false`. [More](#restricted-file-types) |
|
||||
| **`staticDir`** | The folder directory to use to store media in. Can be either an absolute path or relative to the directory that contains your config. Defaults to your collection slug |
|
||||
| **`trimOptions`** | An object passed to the the Sharp image library to trim the uploaded file. [More](https://sharp.pixelplumbing.com/api-resize#trim) |
|
||||
| **`withMetadata`** | If specified, appends metadata to the output image file. Accepts a boolean or a function that receives `metadata` and `req`, returning a boolean. |
|
||||
| **`hideFileInputOnCreate`** | Set to `true` to prevent the admin UI from showing file inputs during document creation, useful for programmatic file generation. |
|
||||
| **`hideRemoveFile`** | Set to `true` to prevent the admin UI having a way to remove an existing file while editing. |
|
||||
| **`modifyResponseHeaders`** | Accepts an object with existing `headers` and allows you to manipulate the response headers for media files. [More](#modifying-response-headers) |
|
||||
|
||||
### Payload-wide Upload Options
|
||||
|
||||
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload-monorepo",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -132,12 +132,12 @@
|
||||
"devDependencies": {
|
||||
"@jest/globals": "29.7.0",
|
||||
"@libsql/client": "0.14.0",
|
||||
"@next/bundle-analyzer": "15.3.2",
|
||||
"@next/bundle-analyzer": "15.4.4",
|
||||
"@payloadcms/db-postgres": "workspace:*",
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@payloadcms/eslint-plugin": "workspace:*",
|
||||
"@payloadcms/live-preview-react": "workspace:*",
|
||||
"@playwright/test": "1.50.0",
|
||||
"@playwright/test": "1.54.1",
|
||||
"@sentry/nextjs": "^8.33.1",
|
||||
"@sentry/node": "^8.33.1",
|
||||
"@swc-node/register": "1.10.10",
|
||||
@@ -147,8 +147,8 @@
|
||||
"@types/jest": "29.5.12",
|
||||
"@types/minimist": "1.2.5",
|
||||
"@types/node": "22.15.30",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/shelljs": "0.8.15",
|
||||
"chalk": "^4.1.2",
|
||||
"comment-json": "^4.2.3",
|
||||
@@ -168,12 +168,12 @@
|
||||
"lint-staged": "15.2.7",
|
||||
"minimist": "1.2.8",
|
||||
"mongodb-memory-server": "10.1.4",
|
||||
"next": "15.3.2",
|
||||
"next": "15.4.4",
|
||||
"open": "^10.1.0",
|
||||
"p-limit": "^5.0.0",
|
||||
"pg": "8.16.3",
|
||||
"playwright": "1.50.0",
|
||||
"playwright-core": "1.50.0",
|
||||
"playwright": "1.54.1",
|
||||
"playwright-core": "1.54.1",
|
||||
"prettier": "3.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/admin-bar",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "An admin bar for React apps using Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
@@ -42,8 +42,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "create-payload-app",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-mongodb",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "The officially supported MongoDB database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-postgres",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "The officially supported Postgres database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-sqlite",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "The officially supported SQLite database adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -29,8 +29,8 @@ export const countDistinct: CountDistinct = async function countDistinct(
|
||||
.limit(1)
|
||||
.$dynamic()
|
||||
|
||||
joins.forEach(({ condition, table }) => {
|
||||
query = query.leftJoin(table, condition)
|
||||
joins.forEach(({ type, condition, table }) => {
|
||||
query = query[type ?? 'leftJoin'](table, condition)
|
||||
})
|
||||
|
||||
// When we have any joins, we need to count each individual ID only once.
|
||||
|
||||
@@ -60,6 +60,10 @@ const createConstraint = ({
|
||||
formattedOperator = '='
|
||||
}
|
||||
|
||||
if (pathSegments.length === 1) {
|
||||
return `EXISTS (SELECT 1 FROM json_each("${pathSegments[0]}") AS ${newAlias} WHERE ${newAlias}.value ${formattedOperator} '${formattedValue}')`
|
||||
}
|
||||
|
||||
return `EXISTS (
|
||||
SELECT 1
|
||||
FROM json_each(${alias}.value -> '${pathSegments[0]}') AS ${newAlias}
|
||||
@@ -68,21 +72,38 @@ const createConstraint = ({
|
||||
}
|
||||
|
||||
export const createJSONQuery = ({
|
||||
column,
|
||||
operator,
|
||||
pathSegments,
|
||||
rawColumn,
|
||||
table,
|
||||
treatAsArray,
|
||||
treatRootAsArray,
|
||||
value,
|
||||
}: CreateJSONQueryArgs): string => {
|
||||
if ((operator === 'in' || operator === 'not_in') && Array.isArray(value)) {
|
||||
let sql = ''
|
||||
for (const [i, v] of value.entries()) {
|
||||
sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, rawColumn, table, treatAsArray, treatRootAsArray, value: v })} ${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}`
|
||||
}
|
||||
return sql
|
||||
}
|
||||
|
||||
if (treatAsArray?.includes(pathSegments[1]!) && table) {
|
||||
return fromArray({
|
||||
operator,
|
||||
pathSegments,
|
||||
table,
|
||||
treatAsArray,
|
||||
value,
|
||||
value: value as CreateConstraintArgs['value'],
|
||||
})
|
||||
}
|
||||
|
||||
return createConstraint({ alias: table, operator, pathSegments, treatAsArray, value })
|
||||
return createConstraint({
|
||||
alias: table,
|
||||
operator,
|
||||
pathSegments,
|
||||
treatAsArray,
|
||||
value: value as CreateConstraintArgs['value'],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/db-vercel-postgres",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "Vercel Postgres adapter for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/drizzle",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "A library of shared functions used by different payload database adapters",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -6,41 +6,58 @@ import toSnakeCase from 'to-snake-case'
|
||||
import type { DrizzleAdapter } from './types.js'
|
||||
|
||||
import { findMany } from './find/findMany.js'
|
||||
import { buildQuery } from './queries/buildQuery.js'
|
||||
import { getTransaction } from './utilities/getTransaction.js'
|
||||
|
||||
export const deleteMany: DeleteMany = async function deleteMany(
|
||||
this: DrizzleAdapter,
|
||||
{ collection, req, where },
|
||||
{ collection, req, where: whereArg },
|
||||
) {
|
||||
const db = await getTransaction(this, req)
|
||||
const collectionConfig = this.payload.collections[collection].config
|
||||
|
||||
const tableName = this.tableNameMap.get(toSnakeCase(collectionConfig.slug))
|
||||
|
||||
const result = await findMany({
|
||||
const table = this.tables[tableName]
|
||||
|
||||
const { joins, where } = buildQuery({
|
||||
adapter: this,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
joins: false,
|
||||
limit: 0,
|
||||
locale: req?.locale,
|
||||
page: 1,
|
||||
pagination: false,
|
||||
req,
|
||||
tableName,
|
||||
where,
|
||||
where: whereArg,
|
||||
})
|
||||
|
||||
const ids = []
|
||||
let whereToUse = where
|
||||
|
||||
result.docs.forEach((data) => {
|
||||
ids.push(data.id)
|
||||
})
|
||||
|
||||
if (ids.length > 0) {
|
||||
await this.deleteWhere({
|
||||
db,
|
||||
if (joins?.length) {
|
||||
// Difficult to support joins (through where referencing other tables) in deleteMany. => 2 separate queries.
|
||||
// We can look into supporting this using one single query (through a subquery) in the future, though that's difficult to do in a generic way.
|
||||
const result = await findMany({
|
||||
adapter: this,
|
||||
fields: collectionConfig.flattenedFields,
|
||||
joins: false,
|
||||
limit: 0,
|
||||
locale: req?.locale,
|
||||
page: 1,
|
||||
pagination: false,
|
||||
req,
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
tableName,
|
||||
where: inArray(this.tables[tableName].id, ids),
|
||||
where: whereArg,
|
||||
})
|
||||
|
||||
whereToUse = inArray(
|
||||
table.id,
|
||||
result.docs.map((doc) => doc.id),
|
||||
)
|
||||
}
|
||||
|
||||
await this.deleteWhere({
|
||||
db,
|
||||
tableName,
|
||||
where: whereToUse,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import type { LibSQLDatabase } from 'drizzle-orm/libsql'
|
||||
import type { SQLiteSelect, SQLiteSelectBase } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
import { and, asc, count, desc, eq, or, sql } from 'drizzle-orm'
|
||||
import { and, asc, count, desc, eq, getTableName, or, sql } from 'drizzle-orm'
|
||||
import {
|
||||
appendVersionToQueryKey,
|
||||
buildVersionCollectionFields,
|
||||
combineQueries,
|
||||
type FlattenedField,
|
||||
getFieldByPath,
|
||||
getQueryDraftsSort,
|
||||
type JoinQuery,
|
||||
type SelectMode,
|
||||
@@ -31,7 +33,7 @@ import {
|
||||
resolveBlockTableName,
|
||||
} from '../utilities/validateExistingBlockIsIdentical.js'
|
||||
|
||||
const flattenAllWherePaths = (where: Where, paths: string[]) => {
|
||||
const flattenAllWherePaths = (where: Where, paths: { path: string; ref: any }[]) => {
|
||||
for (const k in where) {
|
||||
if (['AND', 'OR'].includes(k.toUpperCase())) {
|
||||
if (Array.isArray(where[k])) {
|
||||
@@ -41,7 +43,7 @@ const flattenAllWherePaths = (where: Where, paths: string[]) => {
|
||||
}
|
||||
} else {
|
||||
// TODO: explore how to support arrays/relationship querying.
|
||||
paths.push(k.split('.').join('_'))
|
||||
paths.push({ path: k.split('.').join('_'), ref: where })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +61,11 @@ const buildSQLWhere = (where: Where, alias: string) => {
|
||||
}
|
||||
} else {
|
||||
const payloadOperator = Object.keys(where[k])[0]
|
||||
|
||||
const value = where[k][payloadOperator]
|
||||
if (payloadOperator === '$raw') {
|
||||
return sql.raw(value)
|
||||
}
|
||||
|
||||
return operatorMap[payloadOperator](sql.raw(`"${alias}"."${k.split('.').join('_')}"`), value)
|
||||
}
|
||||
@@ -472,7 +478,7 @@ export const traverseFields = ({
|
||||
|
||||
const sortPath = sanitizedSort.split('.').join('_')
|
||||
|
||||
const wherePaths: string[] = []
|
||||
const wherePaths: { path: string; ref: any }[] = []
|
||||
|
||||
if (where) {
|
||||
flattenAllWherePaths(where, wherePaths)
|
||||
@@ -492,9 +498,50 @@ export const traverseFields = ({
|
||||
sortPath: sql`${sortColumn ? sortColumn : null}`.as('sortPath'),
|
||||
}
|
||||
|
||||
const collectionQueryWhere: any[] = []
|
||||
// Select for WHERE and Fallback NULL
|
||||
for (const path of wherePaths) {
|
||||
if (adapter.tables[joinCollectionTableName][path]) {
|
||||
for (const { path, ref } of wherePaths) {
|
||||
const collectioConfig = adapter.payload.collections[collection].config
|
||||
const field = getFieldByPath({ fields: collectioConfig.flattenedFields, path })
|
||||
|
||||
if (field && field.field.type === 'select' && field.field.hasMany) {
|
||||
let tableName = adapter.tableNameMap.get(
|
||||
`${toSnakeCase(collection)}_${toSnakeCase(path)}`,
|
||||
)
|
||||
let parentTable = getTableName(table)
|
||||
|
||||
if (adapter.schemaName) {
|
||||
tableName = `"${adapter.schemaName}"."${tableName}"`
|
||||
parentTable = `"${adapter.schemaName}"."${parentTable}"`
|
||||
}
|
||||
|
||||
if (adapter.name === 'postgres') {
|
||||
selectFields[path] = sql
|
||||
.raw(
|
||||
`(select jsonb_agg(${tableName}.value) from ${tableName} where ${tableName}.parent_id = ${parentTable}.id)`,
|
||||
)
|
||||
.as(path)
|
||||
} else {
|
||||
selectFields[path] = sql
|
||||
.raw(
|
||||
`(select json_group_array(${tableName}.value) from ${tableName} where ${tableName}.parent_id = ${parentTable}.id)`,
|
||||
)
|
||||
.as(path)
|
||||
}
|
||||
|
||||
const constraint = ref[path]
|
||||
const operator = Object.keys(constraint)[0]
|
||||
const value: any = Object.values(constraint)[0]
|
||||
|
||||
const query = adapter.createJSONQuery({
|
||||
column: `"${path}"`,
|
||||
operator,
|
||||
pathSegments: [field.field.name],
|
||||
table: parentTable,
|
||||
value,
|
||||
})
|
||||
ref[path] = { $raw: query }
|
||||
} else if (adapter.tables[joinCollectionTableName][path]) {
|
||||
selectFields[path] = sql`${adapter.tables[joinCollectionTableName][path]}`.as(path)
|
||||
// Allow to filter by collectionSlug
|
||||
} else if (path !== 'relationTo') {
|
||||
@@ -502,7 +549,10 @@ export const traverseFields = ({
|
||||
}
|
||||
}
|
||||
|
||||
const query = db.select(selectFields).from(adapter.tables[joinCollectionTableName])
|
||||
let query: any = db.select(selectFields).from(adapter.tables[joinCollectionTableName])
|
||||
if (collectionQueryWhere.length) {
|
||||
query = query.where(and(...collectionQueryWhere))
|
||||
}
|
||||
if (currentQuery === null) {
|
||||
currentQuery = query as unknown as SQLSelect
|
||||
} else {
|
||||
|
||||
@@ -30,8 +30,8 @@ export const countDistinct: CountDistinct = async function countDistinct(
|
||||
.limit(1)
|
||||
.$dynamic()
|
||||
|
||||
joins.forEach(({ condition, table }) => {
|
||||
query = query.leftJoin(table as PgTableWithColumns<any>, condition)
|
||||
joins.forEach(({ type, condition, table }) => {
|
||||
query = query[type ?? 'leftJoin'](table as PgTableWithColumns<any>, condition)
|
||||
})
|
||||
|
||||
// When we have any joins, we need to count each individual ID only once.
|
||||
|
||||
@@ -28,6 +28,8 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat
|
||||
})
|
||||
.join('.')
|
||||
|
||||
const fullPath = pathSegments.length === 1 ? '$[*]' : `$.${jsonPaths}`
|
||||
|
||||
let sql = ''
|
||||
|
||||
if (['in', 'not_in'].includes(operator) && Array.isArray(value)) {
|
||||
@@ -35,13 +37,13 @@ export const createJSONQuery = ({ column, operator, pathSegments, value }: Creat
|
||||
sql = `${sql}${createJSONQuery({ column, operator: operator === 'in' ? 'equals' : 'not_equals', pathSegments, value: item })}${i === value.length - 1 ? '' : ` ${operator === 'in' ? 'OR' : 'AND'} `}`
|
||||
})
|
||||
} else if (operator === 'exists') {
|
||||
sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '$.${jsonPaths}')`
|
||||
sql = `${value === false ? 'NOT ' : ''}jsonb_path_exists(${columnName}, '${fullPath}')`
|
||||
} else if (['not_like'].includes(operator)) {
|
||||
const mappedOperator = operatorMap[operator]
|
||||
|
||||
sql = `NOT jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')`
|
||||
sql = `NOT jsonb_path_exists(${columnName}, '${fullPath} ? (@ ${mappedOperator.substring(1)} ${sanitizeValue(value, operator)})')`
|
||||
} else {
|
||||
sql = `jsonb_path_exists(${columnName}, '$.${jsonPaths} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')`
|
||||
sql = `jsonb_path_exists(${columnName}, '${fullPath} ? (@ ${operatorMap[operator]} ${sanitizeValue(value, operator)})')`
|
||||
}
|
||||
|
||||
return sql
|
||||
|
||||
@@ -219,7 +219,10 @@ export function parseParams({
|
||||
|
||||
if (
|
||||
operator === 'like' &&
|
||||
(field.type === 'number' || table[columnName].columnType === 'PgUUID')
|
||||
(field.type === 'number' ||
|
||||
field.type === 'relationship' ||
|
||||
field.type === 'upload' ||
|
||||
table[columnName].columnType === 'PgUUID')
|
||||
) {
|
||||
operator = 'equals'
|
||||
}
|
||||
|
||||
@@ -112,9 +112,14 @@ export const sanitizeQueryValue = ({
|
||||
|
||||
if (field.type === 'date' && operator !== 'exists') {
|
||||
if (typeof val === 'string') {
|
||||
formattedValue = new Date(val).toISOString()
|
||||
if (Number.isNaN(Date.parse(formattedValue))) {
|
||||
return { operator, value: undefined }
|
||||
if (val === 'null' || val === '') {
|
||||
formattedValue = null
|
||||
} else {
|
||||
const date = new Date(val)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return { operator, value: undefined }
|
||||
}
|
||||
formattedValue = date.toISOString()
|
||||
}
|
||||
} else if (typeof val === 'number') {
|
||||
formattedValue = new Date(val).toISOString()
|
||||
|
||||
@@ -56,8 +56,8 @@ export const selectDistinct = ({
|
||||
query = query.where(where)
|
||||
}
|
||||
|
||||
joins.forEach(({ condition, table }) => {
|
||||
query = query.leftJoin(table, condition)
|
||||
joins.forEach(({ type, condition, table }) => {
|
||||
query = query[type ?? 'leftJoin'](table, condition)
|
||||
})
|
||||
|
||||
return queryModifier({
|
||||
|
||||
@@ -161,10 +161,11 @@ export type CreateJSONQueryArgs = {
|
||||
column?: Column | string
|
||||
operator: string
|
||||
pathSegments: string[]
|
||||
rawColumn?: SQL<unknown>
|
||||
table?: string
|
||||
treatAsArray?: string[]
|
||||
treatRootAsArray?: boolean
|
||||
value: boolean | number | string
|
||||
value: boolean | number | number[] | string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { DrizzleAdapter } from './types.js'
|
||||
|
||||
import { findMany } from './find/findMany.js'
|
||||
import { upsertRow } from './upsertRow/index.js'
|
||||
import { shouldUseOptimizedUpsertRow } from './upsertRow/shouldUseOptimizedUpsertRow.js'
|
||||
import { getTransaction } from './utilities/getTransaction.js'
|
||||
|
||||
export const updateJobs: UpdateJobs = async function updateMany(
|
||||
@@ -23,6 +24,27 @@ export const updateJobs: UpdateJobs = async function updateMany(
|
||||
const tableName = this.tableNameMap.get(toSnakeCase(collection.slug))
|
||||
const sort = sortArg !== undefined && sortArg !== null ? sortArg : collection.defaultSort
|
||||
|
||||
const useOptimizedUpsertRow = shouldUseOptimizedUpsertRow({
|
||||
data,
|
||||
fields: collection.flattenedFields,
|
||||
})
|
||||
|
||||
if (useOptimizedUpsertRow && id) {
|
||||
const result = await upsertRow({
|
||||
id,
|
||||
adapter: this,
|
||||
data,
|
||||
db,
|
||||
fields: collection.flattenedFields,
|
||||
ignoreResult: returning === false,
|
||||
operation: 'update',
|
||||
req,
|
||||
tableName,
|
||||
})
|
||||
|
||||
return returning === false ? null : [result]
|
||||
}
|
||||
|
||||
const jobs = await findMany({
|
||||
adapter: this,
|
||||
collectionSlug: 'payload-jobs',
|
||||
@@ -42,10 +64,12 @@ export const updateJobs: UpdateJobs = async function updateMany(
|
||||
|
||||
// TODO: We need to batch this to reduce the amount of db calls. This can get very slow if we are updating a lot of rows.
|
||||
for (const job of jobs.docs) {
|
||||
const updateData = {
|
||||
...job,
|
||||
...data,
|
||||
}
|
||||
const updateData = useOptimizedUpsertRow
|
||||
? data
|
||||
: {
|
||||
...job,
|
||||
...data,
|
||||
}
|
||||
|
||||
const result = await upsertRow({
|
||||
id: job.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/email-nodemailer",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "Payload Nodemailer Email Adapter",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/email-resend",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "Payload Resend Email Adapter",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/graphql",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Resolver = (
|
||||
args: {
|
||||
data: Record<string, unknown>
|
||||
locale?: string
|
||||
trash?: boolean
|
||||
where?: Where
|
||||
},
|
||||
context: {
|
||||
@@ -30,6 +31,7 @@ export function countResolver(collection: Collection): Resolver {
|
||||
const options = {
|
||||
collection,
|
||||
req: isolateObjectProperty(req, 'transactionID'),
|
||||
trash: args.trash,
|
||||
where: args.where,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type Resolver<TSlug extends CollectionSlug> = (
|
||||
fallbackLocale?: string
|
||||
id: number | string
|
||||
locale?: string
|
||||
trash?: boolean
|
||||
},
|
||||
context: {
|
||||
req: PayloadRequest
|
||||
@@ -49,6 +50,7 @@ export function getDeleteResolver<TSlug extends CollectionSlug>(
|
||||
collection,
|
||||
depth: 0,
|
||||
req: isolateObjectProperty(req, 'transactionID'),
|
||||
trash: args.trash,
|
||||
}
|
||||
|
||||
const result = await deleteByIDOperation(options)
|
||||
|
||||
@@ -15,6 +15,7 @@ export type Resolver = (
|
||||
page?: number
|
||||
pagination?: boolean
|
||||
sort?: string
|
||||
trash?: boolean
|
||||
where?: Where
|
||||
},
|
||||
context: {
|
||||
@@ -57,6 +58,7 @@ export function findResolver(collection: Collection): Resolver {
|
||||
pagination: args.pagination,
|
||||
req,
|
||||
sort: args.sort,
|
||||
trash: args.trash,
|
||||
where: args.where,
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type Resolver<TData> = (
|
||||
fallbackLocale?: string
|
||||
id: string
|
||||
locale?: string
|
||||
trash?: boolean
|
||||
},
|
||||
context: {
|
||||
req: PayloadRequest
|
||||
@@ -50,6 +51,7 @@ export function findByIDResolver<TSlug extends CollectionSlug>(
|
||||
depth: 0,
|
||||
draft: args.draft,
|
||||
req: isolateObjectProperty(req, 'transactionID'),
|
||||
trash: args.trash,
|
||||
}
|
||||
|
||||
const result = await findByIDOperation(options)
|
||||
|
||||
@@ -10,6 +10,7 @@ export type Resolver<T extends TypeWithID = any> = (
|
||||
fallbackLocale?: string
|
||||
id: number | string
|
||||
locale?: string
|
||||
trash?: boolean
|
||||
},
|
||||
context: {
|
||||
req: PayloadRequest
|
||||
@@ -33,6 +34,7 @@ export function findVersionByIDResolver(collection: Collection): Resolver {
|
||||
collection,
|
||||
depth: 0,
|
||||
req: isolateObjectProperty(req, 'transactionID'),
|
||||
trash: args.trash,
|
||||
}
|
||||
|
||||
const result = await findVersionByIDOperation(options)
|
||||
|
||||
@@ -14,6 +14,7 @@ export type Resolver = (
|
||||
page?: number
|
||||
pagination?: boolean
|
||||
sort?: string
|
||||
trash?: boolean
|
||||
where: Where
|
||||
},
|
||||
context: {
|
||||
@@ -54,6 +55,7 @@ export function findVersionsResolver(collection: Collection): Resolver {
|
||||
pagination: args.pagination,
|
||||
req: isolateObjectProperty(req, 'transactionID'),
|
||||
sort: args.sort,
|
||||
trash: args.trash,
|
||||
where: args.where,
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ export type Resolver<TSlug extends CollectionSlug> = (
|
||||
fallbackLocale?: string
|
||||
id: number | string
|
||||
locale?: string
|
||||
trash?: boolean
|
||||
},
|
||||
context: {
|
||||
req: PayloadRequest
|
||||
@@ -54,6 +55,7 @@ export function updateResolver<TSlug extends CollectionSlug>(
|
||||
depth: 0,
|
||||
draft: args.draft,
|
||||
req: isolateObjectProperty(req, 'transactionID'),
|
||||
trash: args.trash,
|
||||
}
|
||||
|
||||
const result = await updateByIDOperation<TSlug>(options)
|
||||
|
||||
@@ -205,6 +205,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
||||
locale: { type: graphqlResult.types.localeInputType },
|
||||
}
|
||||
: {}),
|
||||
trash: { type: GraphQLBoolean },
|
||||
},
|
||||
resolve: findByIDResolver(collection),
|
||||
}
|
||||
@@ -224,6 +225,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
||||
page: { type: GraphQLInt },
|
||||
pagination: { type: GraphQLBoolean },
|
||||
sort: { type: GraphQLString },
|
||||
trash: { type: GraphQLBoolean },
|
||||
},
|
||||
resolve: findResolver(collection),
|
||||
}
|
||||
@@ -237,6 +239,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
||||
}),
|
||||
args: {
|
||||
draft: { type: GraphQLBoolean },
|
||||
trash: { type: GraphQLBoolean },
|
||||
where: { type: collection.graphQL.whereInputType },
|
||||
...(config.localization
|
||||
? {
|
||||
@@ -292,6 +295,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
||||
locale: { type: graphqlResult.types.localeInputType },
|
||||
}
|
||||
: {}),
|
||||
trash: { type: GraphQLBoolean },
|
||||
},
|
||||
resolve: updateResolver(collection),
|
||||
}
|
||||
@@ -300,6 +304,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
||||
type: collection.graphQL.type,
|
||||
args: {
|
||||
id: { type: new GraphQLNonNull(idType) },
|
||||
trash: { type: GraphQLBoolean },
|
||||
},
|
||||
resolve: getDeleteResolver(collection),
|
||||
}
|
||||
@@ -329,12 +334,12 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
||||
{
|
||||
name: 'createdAt',
|
||||
type: 'date',
|
||||
label: 'Created At',
|
||||
label: ({ t }) => t('general:createdAt'),
|
||||
},
|
||||
{
|
||||
name: 'updatedAt',
|
||||
type: 'date',
|
||||
label: 'Updated At',
|
||||
label: ({ t }) => t('general:updatedAt'),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -359,6 +364,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
||||
locale: { type: graphqlResult.types.localeInputType },
|
||||
}
|
||||
: {}),
|
||||
trash: { type: GraphQLBoolean },
|
||||
},
|
||||
resolve: findVersionByIDResolver(collection),
|
||||
}
|
||||
@@ -385,6 +391,7 @@ export function initCollections({ config, graphqlResult }: InitCollectionsGraphQ
|
||||
page: { type: GraphQLInt },
|
||||
pagination: { type: GraphQLBoolean },
|
||||
sort: { type: GraphQLString },
|
||||
trash: { type: GraphQLBoolean },
|
||||
},
|
||||
resolve: findVersionsResolver(collection),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview-react",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "The official React SDK for Payload Live Preview",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
@@ -46,8 +46,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview-vue",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "The official Vue SDK for Payload Live Preview",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/live-preview",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "The official live preview JavaScript SDK for Payload",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/next",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -117,11 +117,11 @@
|
||||
"@babel/preset-env": "7.27.2",
|
||||
"@babel/preset-react": "7.27.1",
|
||||
"@babel/preset-typescript": "7.27.1",
|
||||
"@next/eslint-plugin-next": "15.3.2",
|
||||
"@next/eslint-plugin-next": "15.4.4",
|
||||
"@payloadcms/eslint-config": "workspace:*",
|
||||
"@types/busboy": "1.5.4",
|
||||
"@types/react": "19.1.0",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/uuid": "10.0.0",
|
||||
"babel-plugin-react-compiler": "19.1.0-rc.2",
|
||||
"esbuild": "0.25.5",
|
||||
|
||||
@@ -38,9 +38,12 @@ export const DocumentTabLink: React.FC<{
|
||||
path: `/${isCollection ? 'collections' : 'globals'}/${entitySlug}`,
|
||||
})
|
||||
|
||||
if (isCollection && segmentThree) {
|
||||
// doc ID
|
||||
docPath += `/${segmentThree}`
|
||||
if (isCollection) {
|
||||
if (segmentThree === 'trash' && segmentFour) {
|
||||
docPath += `/trash/${segmentFour}`
|
||||
} else if (segmentThree) {
|
||||
docPath += `/${segmentThree}`
|
||||
}
|
||||
}
|
||||
|
||||
const href = `${docPath}${hrefFromProps}`
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { DocumentTabConfig, DocumentTabServerProps, ServerProps } from 'payload'
|
||||
import type {
|
||||
DocumentTabConfig,
|
||||
DocumentTabServerPropsOnly,
|
||||
PayloadRequest,
|
||||
SanitizedCollectionConfig,
|
||||
SanitizedGlobalConfig,
|
||||
SanitizedPermissions,
|
||||
} from 'payload'
|
||||
import type React from 'react'
|
||||
|
||||
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
||||
@@ -9,27 +16,24 @@ import './index.scss'
|
||||
|
||||
export const baseClass = 'doc-tab'
|
||||
|
||||
export const DocumentTab: React.FC<
|
||||
{ readonly Pill_Component?: React.FC } & DocumentTabConfig & DocumentTabServerProps
|
||||
> = (props) => {
|
||||
export const DefaultDocumentTab: React.FC<{
|
||||
apiURL?: string
|
||||
collectionConfig?: SanitizedCollectionConfig
|
||||
globalConfig?: SanitizedGlobalConfig
|
||||
path?: string
|
||||
permissions?: SanitizedPermissions
|
||||
req: PayloadRequest
|
||||
tabConfig: { readonly Pill_Component?: React.FC } & DocumentTabConfig
|
||||
}> = (props) => {
|
||||
const {
|
||||
apiURL,
|
||||
collectionConfig,
|
||||
globalConfig,
|
||||
href: tabHref,
|
||||
i18n,
|
||||
isActive: tabIsActive,
|
||||
label,
|
||||
newTab,
|
||||
payload,
|
||||
permissions,
|
||||
Pill,
|
||||
Pill_Component,
|
||||
req,
|
||||
tabConfig: { href: tabHref, isActive: tabIsActive, label, newTab, Pill, Pill_Component },
|
||||
} = props
|
||||
|
||||
const { config } = payload
|
||||
const { routes } = config
|
||||
|
||||
let href = typeof tabHref === 'string' ? tabHref : ''
|
||||
let isActive = typeof tabIsActive === 'boolean' ? tabIsActive : false
|
||||
|
||||
@@ -38,7 +42,7 @@ export const DocumentTab: React.FC<
|
||||
apiURL,
|
||||
collection: collectionConfig,
|
||||
global: globalConfig,
|
||||
routes,
|
||||
routes: req.payload.config.routes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,13 +55,13 @@ export const DocumentTab: React.FC<
|
||||
const labelToRender =
|
||||
typeof label === 'function'
|
||||
? label({
|
||||
t: i18n.t,
|
||||
t: req.i18n.t,
|
||||
})
|
||||
: label
|
||||
|
||||
return (
|
||||
<DocumentTabLink
|
||||
adminRoute={routes.admin}
|
||||
adminRoute={req.payload.config.routes.admin}
|
||||
ariaLabel={labelToRender}
|
||||
baseClass={baseClass}
|
||||
href={href}
|
||||
@@ -72,12 +76,14 @@ export const DocumentTab: React.FC<
|
||||
{RenderServerComponent({
|
||||
Component: Pill,
|
||||
Fallback: Pill_Component,
|
||||
importMap: payload.importMap,
|
||||
importMap: req.payload.importMap,
|
||||
serverProps: {
|
||||
i18n,
|
||||
payload,
|
||||
i18n: req.i18n,
|
||||
payload: req.payload,
|
||||
permissions,
|
||||
} satisfies ServerProps,
|
||||
req,
|
||||
user: req.user,
|
||||
} satisfies DocumentTabServerPropsOnly,
|
||||
})}
|
||||
</Fragment>
|
||||
) : null}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { I18n } from '@payloadcms/translations'
|
||||
import type {
|
||||
DocumentTabClientProps,
|
||||
DocumentTabServerPropsOnly,
|
||||
Payload,
|
||||
PayloadRequest,
|
||||
SanitizedCollectionConfig,
|
||||
SanitizedGlobalConfig,
|
||||
SanitizedPermissions,
|
||||
@@ -12,7 +11,7 @@ import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerCompo
|
||||
import React from 'react'
|
||||
|
||||
import { ShouldRenderTabs } from './ShouldRenderTabs.js'
|
||||
import { DocumentTab } from './Tab/index.js'
|
||||
import { DefaultDocumentTab } from './Tab/index.js'
|
||||
import { getTabs } from './tabs/index.js'
|
||||
import './index.scss'
|
||||
|
||||
@@ -21,12 +20,10 @@ const baseClass = 'doc-tabs'
|
||||
export const DocumentTabs: React.FC<{
|
||||
collectionConfig: SanitizedCollectionConfig
|
||||
globalConfig: SanitizedGlobalConfig
|
||||
i18n: I18n
|
||||
payload: Payload
|
||||
permissions: SanitizedPermissions
|
||||
}> = (props) => {
|
||||
const { collectionConfig, globalConfig, i18n, payload, permissions } = props
|
||||
const { config } = payload
|
||||
req: PayloadRequest
|
||||
}> = ({ collectionConfig, globalConfig, permissions, req }) => {
|
||||
const { config } = req.payload
|
||||
|
||||
const tabs = getTabs({
|
||||
collectionConfig,
|
||||
@@ -38,42 +35,46 @@ export const DocumentTabs: React.FC<{
|
||||
<div className={baseClass}>
|
||||
<div className={`${baseClass}__tabs-container`}>
|
||||
<ul className={`${baseClass}__tabs`}>
|
||||
{tabs?.map(({ tab, viewPath }, index) => {
|
||||
const { condition } = tab || {}
|
||||
{tabs?.map(({ tab: tabConfig, viewPath }, index) => {
|
||||
const { condition } = tabConfig || {}
|
||||
|
||||
const meetsCondition =
|
||||
!condition || condition({ collectionConfig, config, globalConfig, permissions })
|
||||
!condition ||
|
||||
condition({ collectionConfig, config, globalConfig, permissions, req })
|
||||
|
||||
if (!meetsCondition) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (tab?.Component) {
|
||||
if (tabConfig?.Component) {
|
||||
return RenderServerComponent({
|
||||
clientProps: {
|
||||
path: viewPath,
|
||||
} satisfies DocumentTabClientProps,
|
||||
Component: tab.Component,
|
||||
importMap: payload.importMap,
|
||||
Component: tabConfig.Component,
|
||||
importMap: req.payload.importMap,
|
||||
key: `tab-${index}`,
|
||||
serverProps: {
|
||||
collectionConfig,
|
||||
globalConfig,
|
||||
i18n,
|
||||
payload,
|
||||
i18n: req.i18n,
|
||||
payload: req.payload,
|
||||
permissions,
|
||||
req,
|
||||
user: req.user,
|
||||
} satisfies DocumentTabServerPropsOnly,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DocumentTab
|
||||
<DefaultDocumentTab
|
||||
collectionConfig={collectionConfig}
|
||||
globalConfig={globalConfig}
|
||||
key={`tab-${index}`}
|
||||
path={viewPath}
|
||||
{...{
|
||||
...props,
|
||||
...tab,
|
||||
}}
|
||||
permissions={permissions}
|
||||
req={req}
|
||||
tabConfig={tabConfig}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { I18n } from '@payloadcms/translations'
|
||||
import type {
|
||||
Payload,
|
||||
PayloadRequest,
|
||||
SanitizedCollectionConfig,
|
||||
SanitizedGlobalConfig,
|
||||
SanitizedPermissions,
|
||||
@@ -18,11 +18,10 @@ export const DocumentHeader: React.FC<{
|
||||
collectionConfig?: SanitizedCollectionConfig
|
||||
globalConfig?: SanitizedGlobalConfig
|
||||
hideTabs?: boolean
|
||||
i18n: I18n
|
||||
payload: Payload
|
||||
permissions: SanitizedPermissions
|
||||
req: PayloadRequest
|
||||
}> = (props) => {
|
||||
const { collectionConfig, globalConfig, hideTabs, i18n, payload, permissions } = props
|
||||
const { collectionConfig, globalConfig, hideTabs, permissions, req } = props
|
||||
|
||||
return (
|
||||
<Gutter className={baseClass}>
|
||||
@@ -31,9 +30,8 @@ export const DocumentHeader: React.FC<{
|
||||
<DocumentTabs
|
||||
collectionConfig={collectionConfig}
|
||||
globalConfig={globalConfig}
|
||||
i18n={i18n}
|
||||
payload={payload}
|
||||
permissions={permissions}
|
||||
req={req}
|
||||
/>
|
||||
)}
|
||||
</Gutter>
|
||||
|
||||
@@ -5,8 +5,6 @@ import type {
|
||||
SanitizedGlobalConfig,
|
||||
} from 'payload'
|
||||
|
||||
import { fieldAffectsData } from 'payload/shared'
|
||||
|
||||
import { getRouteWithoutAdmin, isAdminRoute } from './shared.js'
|
||||
|
||||
type Args = {
|
||||
@@ -35,7 +33,7 @@ export function getRouteInfo({
|
||||
if (isAdminRoute({ adminRoute, config, route })) {
|
||||
const routeWithoutAdmin = getRouteWithoutAdmin({ adminRoute, route })
|
||||
const routeSegments = routeWithoutAdmin.split('/').filter(Boolean)
|
||||
const [entityType, entitySlug, createOrID] = routeSegments
|
||||
const [entityType, entitySlug, segment3, segment4] = routeSegments
|
||||
const collectionSlug = entityType === 'collections' ? entitySlug : undefined
|
||||
const globalSlug = entityType === 'globals' ? entitySlug : undefined
|
||||
|
||||
@@ -58,12 +56,17 @@ export function getRouteInfo({
|
||||
}
|
||||
}
|
||||
|
||||
const docID =
|
||||
collectionSlug && createOrID !== 'create'
|
||||
? idType === 'number'
|
||||
? Number(createOrID)
|
||||
: createOrID
|
||||
: undefined
|
||||
let docID: number | string | undefined
|
||||
|
||||
if (collectionSlug) {
|
||||
if (segment3 === 'trash' && segment4) {
|
||||
// /collections/:slug/trash/:id
|
||||
docID = idType === 'number' ? Number(segment4) : segment4
|
||||
} else if (segment3 && segment3 !== 'create') {
|
||||
// /collections/:slug/:id
|
||||
docID = idType === 'number' ? Number(segment3) : segment3
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
collectionConfig,
|
||||
|
||||
@@ -15,16 +15,18 @@ import {
|
||||
useTranslation,
|
||||
} from '@payloadcms/ui'
|
||||
import { useSearchParams } from 'next/navigation.js'
|
||||
import * as React from 'react'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
import { LocaleSelector } from './LocaleSelector/index.js'
|
||||
import { RenderJSON } from './RenderJSON/index.js'
|
||||
|
||||
const baseClass = 'query-inspector'
|
||||
|
||||
export const APIViewClient: React.FC = () => {
|
||||
const { id, collectionSlug, globalSlug, initialData } = useDocumentInfo()
|
||||
const { id, collectionSlug, globalSlug, initialData, isTrashed } = useDocumentInfo()
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const { i18n, t } = useTranslation()
|
||||
@@ -69,10 +71,13 @@ export const APIViewClient: React.FC = () => {
|
||||
const [authenticated, setAuthenticated] = React.useState<boolean>(true)
|
||||
const [fullscreen, setFullscreen] = React.useState<boolean>(false)
|
||||
|
||||
const trashParam = typeof initialData?.deletedAt === 'string'
|
||||
|
||||
const params = new URLSearchParams({
|
||||
depth,
|
||||
draft: String(draft),
|
||||
locale,
|
||||
trash: trashParam ? 'true' : 'false',
|
||||
}).toString()
|
||||
|
||||
const fetchURL = `${serverURL}${apiRoute}${docEndpoint}?${params}`
|
||||
@@ -114,6 +119,7 @@ export const APIViewClient: React.FC = () => {
|
||||
globalLabel={globalConfig?.label}
|
||||
globalSlug={globalSlug}
|
||||
id={id}
|
||||
isTrashed={isTrashed}
|
||||
pluralLabel={collectionConfig ? collectionConfig?.labels?.plural : undefined}
|
||||
useAsTitle={collectionConfig ? collectionConfig?.admin?.useAsTitle : undefined}
|
||||
view="API"
|
||||
|
||||
@@ -137,9 +137,8 @@ export async function Account({ initPageResult, params, searchParams }: AdminVie
|
||||
<DocumentHeader
|
||||
collectionConfig={collectionConfig}
|
||||
hideTabs
|
||||
i18n={i18n}
|
||||
payload={payload}
|
||||
permissions={permissions}
|
||||
req={req}
|
||||
/>
|
||||
<HydrateAuthProvider permissions={permissions} />
|
||||
{RenderServerComponent({
|
||||
|
||||
40
packages/next/src/views/CollectionTrash/index.tsx
Normal file
40
packages/next/src/views/CollectionTrash/index.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { AdminViewServerProps, ListQuery } from 'payload'
|
||||
import type React from 'react'
|
||||
|
||||
import { notFound } from 'next/navigation.js'
|
||||
|
||||
import { renderListView } from '../List/index.js'
|
||||
|
||||
type RenderTrashViewArgs = {
|
||||
customCellProps?: Record<string, any>
|
||||
disableBulkDelete?: boolean
|
||||
disableBulkEdit?: boolean
|
||||
disableQueryPresets?: boolean
|
||||
drawerSlug?: string
|
||||
enableRowSelections: boolean
|
||||
overrideEntityVisibility?: boolean
|
||||
query: ListQuery
|
||||
redirectAfterDelete?: boolean
|
||||
redirectAfterDuplicate?: boolean
|
||||
redirectAfterRestore?: boolean
|
||||
} & AdminViewServerProps
|
||||
|
||||
export const TrashView: React.FC<Omit<RenderTrashViewArgs, 'enableRowSelections'>> = async (
|
||||
args,
|
||||
) => {
|
||||
try {
|
||||
const { List: TrashList } = await renderListView({
|
||||
...args,
|
||||
enableRowSelections: true,
|
||||
trash: true,
|
||||
viewType: 'trash',
|
||||
})
|
||||
|
||||
return TrashList
|
||||
} catch (error) {
|
||||
if (error.message === 'not-found') {
|
||||
notFound()
|
||||
}
|
||||
console.error(error) // eslint-disable-line no-console
|
||||
}
|
||||
}
|
||||
35
packages/next/src/views/CollectionTrash/metadata.ts
Normal file
35
packages/next/src/views/CollectionTrash/metadata.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { SanitizedCollectionConfig } from 'payload'
|
||||
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
|
||||
import type { GenerateViewMetadata } from '../Root/index.js'
|
||||
|
||||
import { generateMetadata } from '../../utilities/meta.js'
|
||||
|
||||
export const generateCollectionTrashMetadata = async (
|
||||
args: {
|
||||
collectionConfig: SanitizedCollectionConfig
|
||||
} & Parameters<GenerateViewMetadata>[0],
|
||||
): Promise<Metadata> => {
|
||||
const { collectionConfig, config, i18n } = args
|
||||
|
||||
let title: string = ''
|
||||
const description: string = ''
|
||||
const keywords: string = ''
|
||||
|
||||
if (collectionConfig) {
|
||||
title = getTranslation(collectionConfig.labels.plural, i18n)
|
||||
}
|
||||
|
||||
title = `${title ? `${title} ` : title}${i18n.t('general:trash')}`
|
||||
|
||||
return generateMetadata({
|
||||
...(config.admin.meta || {}),
|
||||
description,
|
||||
keywords,
|
||||
serverURL: config.serverURL,
|
||||
title,
|
||||
...(collectionConfig?.admin?.meta || {}),
|
||||
})
|
||||
}
|
||||
@@ -85,7 +85,14 @@ export const CreateFirstUserClient: React.FC<{
|
||||
return (
|
||||
<Form
|
||||
action={`${serverURL}${apiRoute}/${userSlug}/first-register`}
|
||||
initialState={initialState}
|
||||
initialState={{
|
||||
...initialState,
|
||||
'confirm-password': {
|
||||
...initialState['confirm-password'],
|
||||
valid: initialState['confirm-password']['valid'] || false,
|
||||
value: initialState['confirm-password']['value'] || '',
|
||||
},
|
||||
}}
|
||||
method="POST"
|
||||
onChange={[onChange]}
|
||||
onSuccess={handleFirstRegister}
|
||||
|
||||
@@ -15,6 +15,7 @@ type Args = {
|
||||
locale?: Locale
|
||||
payload: Payload
|
||||
req?: PayloadRequest
|
||||
segments?: string[]
|
||||
user?: TypedUser
|
||||
}
|
||||
|
||||
@@ -25,12 +26,15 @@ export const getDocumentData = async ({
|
||||
locale,
|
||||
payload,
|
||||
req,
|
||||
segments,
|
||||
user,
|
||||
}: Args): Promise<null | Record<string, unknown> | TypeWithID> => {
|
||||
const id = sanitizeID(idArg)
|
||||
let resolvedData: Record<string, unknown> | TypeWithID = null
|
||||
const { transactionID, ...rest } = req
|
||||
|
||||
const isTrashedDoc = segments?.[2] === 'trash' && typeof segments?.[3] === 'string' // id exists at segment 3
|
||||
|
||||
try {
|
||||
if (collectionSlug && id) {
|
||||
resolvedData = await payload.findByID({
|
||||
@@ -44,6 +48,7 @@ export const getDocumentData = async ({
|
||||
req: {
|
||||
...rest,
|
||||
},
|
||||
trash: isTrashedDoc ? true : false,
|
||||
user,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,7 +113,13 @@ export const getDocumentView = ({
|
||||
// --> /collections/:collectionSlug/:id/api
|
||||
// --> /collections/:collectionSlug/:id/versions
|
||||
// --> /collections/:collectionSlug/:id/<custom-segment>
|
||||
// --> /collections/:collectionSlug/trash/:id
|
||||
case 4: {
|
||||
// --> /collections/:collectionSlug/trash/:id
|
||||
if (segment3 === 'trash' && segment4) {
|
||||
View = getCustomViewByKey(views, 'default') || DefaultEditView
|
||||
break
|
||||
}
|
||||
switch (segment4) {
|
||||
// --> /collections/:collectionSlug/:id/api
|
||||
case 'api': {
|
||||
@@ -167,18 +173,86 @@ export const getDocumentView = ({
|
||||
break
|
||||
}
|
||||
|
||||
// --> /collections/:collectionSlug/trash/:id/api
|
||||
// --> /collections/:collectionSlug/trash/:id/versions
|
||||
// --> /collections/:collectionSlug/trash/:id/<custom-segment>
|
||||
// --> /collections/:collectionSlug/:id/versions/:version
|
||||
// --> /collections/:collectionSlug/:id/<custom-segment>/<custom-segment>
|
||||
default: {
|
||||
// --> /collections/:collectionSlug/:id/versions/:version
|
||||
if (segment4 === 'versions') {
|
||||
case 5: {
|
||||
// --> /collections/:slug/trash/:id/api
|
||||
if (segment3 === 'trash') {
|
||||
switch (segment5) {
|
||||
case 'api': {
|
||||
if (collectionConfig?.admin?.hideAPIURL !== true) {
|
||||
View = getCustomViewByKey(views, 'api') || DefaultAPIView
|
||||
}
|
||||
break
|
||||
}
|
||||
// --> /collections/:slug/trash/:id/versions
|
||||
case 'versions': {
|
||||
if (docPermissions?.readVersions) {
|
||||
View = getCustomViewByKey(views, 'versions') || DefaultVersionsView
|
||||
} else {
|
||||
View = UnauthorizedViewWithGutter
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
View = getCustomViewByKey(views, 'default') || DefaultEditView
|
||||
break
|
||||
}
|
||||
}
|
||||
// --> /collections/:collectionSlug/:id/versions/:version
|
||||
} else if (segment4 === 'versions') {
|
||||
if (docPermissions?.readVersions) {
|
||||
View = getCustomViewByKey(views, 'version') || DefaultVersionView
|
||||
} else {
|
||||
View = UnauthorizedViewWithGutter
|
||||
}
|
||||
} else {
|
||||
// --> /collections/:collectionSlug/:id/<custom-segment>/<custom-segment>
|
||||
// --> /collections/:collectionSlug/:id/<custom>/<custom>
|
||||
const baseRoute = [
|
||||
adminRoute !== '/' && adminRoute,
|
||||
collectionEntity,
|
||||
collectionSlug,
|
||||
segment3,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
|
||||
const currentRoute = [baseRoute, segment4, segment5, ...remainingSegments]
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
|
||||
const { Component: CustomViewComponent, viewKey: customViewKey } = getCustomViewByRoute({
|
||||
baseRoute,
|
||||
currentRoute,
|
||||
views,
|
||||
})
|
||||
|
||||
if (customViewKey) {
|
||||
viewKey = customViewKey
|
||||
View = CustomViewComponent
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
// --> /collections/:collectionSlug/trash/:id/versions/:version
|
||||
// --> /collections/:collectionSlug/:id/<custom>/<custom>/<custom...>
|
||||
default: {
|
||||
// --> /collections/:collectionSlug/trash/:id/versions/:version
|
||||
const isTrashedVersionView = segment3 === 'trash' && segment5 === 'versions'
|
||||
|
||||
if (isTrashedVersionView) {
|
||||
if (docPermissions?.readVersions) {
|
||||
View = getCustomViewByKey(views, 'version') || DefaultVersionView
|
||||
} else {
|
||||
View = UnauthorizedViewWithGutter
|
||||
}
|
||||
} else {
|
||||
// --> /collections/:collectionSlug/:id/<custom>/<custom>/<custom...>
|
||||
const baseRoute = [
|
||||
adminRoute !== '/' && adminRoute,
|
||||
collectionEntity,
|
||||
|
||||
@@ -15,6 +15,7 @@ export type GenerateEditViewMetadata = (
|
||||
args: {
|
||||
collectionConfig?: null | SanitizedCollectionConfig
|
||||
globalConfig?: null | SanitizedGlobalConfig
|
||||
isReadOnly?: boolean
|
||||
view?: keyof EditConfig
|
||||
} & Parameters<GenerateViewMetadata>[0],
|
||||
) => Promise<Metadata>
|
||||
@@ -42,6 +43,11 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
|
||||
fn = generateEditViewMetadata
|
||||
}
|
||||
|
||||
// `/collections/:collection/trash/:id`
|
||||
if (segments.length === 4 && segments[2] === 'trash') {
|
||||
fn = (args) => generateEditViewMetadata({ ...args, isReadOnly: true })
|
||||
}
|
||||
|
||||
// `/:collection/:id/:view`
|
||||
if (params.segments.length === 4) {
|
||||
switch (params.segments[3]) {
|
||||
@@ -69,6 +75,25 @@ export const getMetaBySegment: GenerateEditViewMetadata = async ({
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// `/collections/:collection/trash/:id/:view`
|
||||
if (segments.length === 5 && segments[2] === 'trash') {
|
||||
switch (segments[4]) {
|
||||
case 'api':
|
||||
fn = generateAPIViewMetadata
|
||||
break
|
||||
case 'versions':
|
||||
fn = generateVersionsViewMetadata
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// `/collections/:collection/trash/:id/versions/:versionID`
|
||||
if (segments.length === 6 && segments[2] === 'trash' && segments[4] === 'versions') {
|
||||
fn = generateVersionViewMetadata
|
||||
}
|
||||
}
|
||||
|
||||
if (isGlobal) {
|
||||
|
||||
@@ -65,6 +65,7 @@ export const renderDocument = async ({
|
||||
redirectAfterCreate,
|
||||
redirectAfterDelete,
|
||||
redirectAfterDuplicate,
|
||||
redirectAfterRestore,
|
||||
searchParams,
|
||||
versions,
|
||||
viewType,
|
||||
@@ -74,6 +75,7 @@ export const renderDocument = async ({
|
||||
readonly redirectAfterCreate?: boolean
|
||||
readonly redirectAfterDelete?: boolean
|
||||
readonly redirectAfterDuplicate?: boolean
|
||||
readonly redirectAfterRestore?: boolean
|
||||
versions?: RenderDocumentVersionsProperties
|
||||
} & AdminViewServerProps): Promise<{
|
||||
data: Data
|
||||
@@ -108,16 +110,18 @@ export const renderDocument = async ({
|
||||
|
||||
// Fetch the doc required for the view
|
||||
let doc =
|
||||
initialData ||
|
||||
(await getDocumentData({
|
||||
id: idFromArgs,
|
||||
collectionSlug,
|
||||
globalSlug,
|
||||
locale,
|
||||
payload,
|
||||
req,
|
||||
user,
|
||||
}))
|
||||
!idFromArgs && !globalSlug
|
||||
? initialData || null
|
||||
: await getDocumentData({
|
||||
id: idFromArgs,
|
||||
collectionSlug,
|
||||
globalSlug,
|
||||
locale,
|
||||
payload,
|
||||
req,
|
||||
segments,
|
||||
user,
|
||||
})
|
||||
|
||||
if (isEditing && !doc) {
|
||||
// If it's a collection document that doesn't exist, redirect to collection list
|
||||
@@ -134,6 +138,8 @@ export const renderDocument = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const isTrashedDoc = typeof doc?.deletedAt === 'string'
|
||||
|
||||
const [
|
||||
docPreferences,
|
||||
{ docPermissions, hasPublishPermission, hasSavePermission },
|
||||
@@ -202,6 +208,7 @@ export const renderDocument = async ({
|
||||
globalSlug,
|
||||
locale: locale?.code,
|
||||
operation,
|
||||
readOnly: isTrashedDoc,
|
||||
renderAllFields: true,
|
||||
req,
|
||||
schemaPath: collectionSlug || globalSlug,
|
||||
@@ -389,12 +396,14 @@ export const renderDocument = async ({
|
||||
initialState={formState}
|
||||
isEditing={isEditing}
|
||||
isLocked={isLocked}
|
||||
isTrashed={isTrashedDoc}
|
||||
key={locale?.code}
|
||||
lastUpdateTime={lastUpdateTime}
|
||||
mostRecentVersionIsAutosaved={mostRecentVersionIsAutosaved}
|
||||
redirectAfterCreate={redirectAfterCreate}
|
||||
redirectAfterDelete={redirectAfterDelete}
|
||||
redirectAfterDuplicate={redirectAfterDuplicate}
|
||||
redirectAfterRestore={redirectAfterRestore}
|
||||
unpublishedVersionCount={unpublishedVersionCount}
|
||||
versionCount={versionCount}
|
||||
>
|
||||
@@ -408,9 +417,8 @@ export const renderDocument = async ({
|
||||
<DocumentHeader
|
||||
collectionConfig={collectionConfig}
|
||||
globalConfig={globalConfig}
|
||||
i18n={i18n}
|
||||
payload={payload}
|
||||
permissions={permissions}
|
||||
req={req}
|
||||
/>
|
||||
)}
|
||||
<HydrateAuthProvider permissions={permissions} />
|
||||
|
||||
@@ -16,6 +16,7 @@ export const generateEditViewMetadata: GenerateEditViewMetadata = async ({
|
||||
globalConfig,
|
||||
i18n,
|
||||
isEditing,
|
||||
isReadOnly = false,
|
||||
view = 'default',
|
||||
}): Promise<Metadata> => {
|
||||
const { t } = i18n
|
||||
@@ -26,11 +27,17 @@ export const generateEditViewMetadata: GenerateEditViewMetadata = async ({
|
||||
? getTranslation(globalConfig.label, i18n)
|
||||
: ''
|
||||
|
||||
const verb = isReadOnly
|
||||
? t('general:viewing')
|
||||
: isEditing
|
||||
? t('general:editing')
|
||||
: t('general:creating')
|
||||
|
||||
const metaToUse: MetaConfig = {
|
||||
...(config.admin.meta || {}),
|
||||
description: `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}`,
|
||||
description: `${verb} - ${entityLabel}`,
|
||||
keywords: `${entityLabel}, Payload, CMS`,
|
||||
title: `${isEditing ? t('general:editing') : t('general:creating')} - ${entityLabel}`,
|
||||
title: `${verb} - ${entityLabel}`,
|
||||
}
|
||||
|
||||
const ogToUse: MetaConfig['openGraph'] = {
|
||||
|
||||
208
packages/next/src/views/List/handleGroupBy.ts
Normal file
208
packages/next/src/views/List/handleGroupBy.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type {
|
||||
ClientConfig,
|
||||
Column,
|
||||
ListQuery,
|
||||
PaginatedDocs,
|
||||
PayloadRequest,
|
||||
SanitizedCollectionConfig,
|
||||
ViewTypes,
|
||||
Where,
|
||||
} from 'payload'
|
||||
|
||||
import { renderTable } from '@payloadcms/ui/rsc'
|
||||
import { formatDate } from '@payloadcms/ui/shared'
|
||||
import { flattenAllFields } from 'payload'
|
||||
|
||||
export const handleGroupBy = async ({
|
||||
clientConfig,
|
||||
collectionConfig,
|
||||
collectionSlug,
|
||||
columns,
|
||||
customCellProps,
|
||||
drawerSlug,
|
||||
enableRowSelections,
|
||||
query,
|
||||
req,
|
||||
trash = false,
|
||||
user,
|
||||
viewType,
|
||||
where: whereWithMergedSearch,
|
||||
}: {
|
||||
clientConfig: ClientConfig
|
||||
collectionConfig: SanitizedCollectionConfig
|
||||
collectionSlug: string
|
||||
columns: any[]
|
||||
customCellProps?: Record<string, any>
|
||||
drawerSlug?: string
|
||||
enableRowSelections?: boolean
|
||||
query?: ListQuery
|
||||
req: PayloadRequest
|
||||
trash?: boolean
|
||||
user: any
|
||||
viewType?: ViewTypes
|
||||
where: Where
|
||||
}): Promise<{
|
||||
columnState: Column[]
|
||||
data: PaginatedDocs
|
||||
Table: null | React.ReactNode | React.ReactNode[]
|
||||
}> => {
|
||||
let Table: React.ReactNode | React.ReactNode[] = null
|
||||
let columnState: Column[]
|
||||
|
||||
const dataByGroup: Record<string, PaginatedDocs> = {}
|
||||
const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug)
|
||||
|
||||
// NOTE: is there a faster/better way to do this?
|
||||
const flattenedFields = flattenAllFields({ fields: collectionConfig.fields })
|
||||
|
||||
const groupByFieldPath = query.groupBy.replace(/^-/, '')
|
||||
|
||||
const groupByField = flattenedFields.find((f) => f.name === groupByFieldPath)
|
||||
|
||||
const relationshipConfig =
|
||||
groupByField?.type === 'relationship'
|
||||
? clientConfig.collections.find((c) => c.slug === groupByField.relationTo)
|
||||
: undefined
|
||||
|
||||
let populate
|
||||
|
||||
if (groupByField?.type === 'relationship' && groupByField.relationTo) {
|
||||
const relationTo =
|
||||
typeof groupByField.relationTo === 'string'
|
||||
? [groupByField.relationTo]
|
||||
: groupByField.relationTo
|
||||
|
||||
if (Array.isArray(relationTo)) {
|
||||
relationTo.forEach((rel) => {
|
||||
if (!populate) {
|
||||
populate = {}
|
||||
}
|
||||
populate[rel] = { [relationshipConfig?.admin.useAsTitle || 'id']: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const distinct = await req.payload.findDistinct({
|
||||
collection: collectionSlug,
|
||||
depth: 1,
|
||||
field: groupByFieldPath,
|
||||
limit: query?.limit ? Number(query.limit) : undefined,
|
||||
locale: req.locale,
|
||||
overrideAccess: false,
|
||||
page: query?.page ? Number(query.page) : undefined,
|
||||
populate,
|
||||
req,
|
||||
sort: query?.groupBy,
|
||||
trash,
|
||||
where: whereWithMergedSearch,
|
||||
})
|
||||
|
||||
const data = {
|
||||
...distinct,
|
||||
docs: distinct.values?.map(() => ({})) || [],
|
||||
values: undefined,
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
distinct.values.map(async (distinctValue, i) => {
|
||||
const potentiallyPopulatedRelationship = distinctValue[groupByFieldPath]
|
||||
|
||||
const valueOrRelationshipID =
|
||||
groupByField?.type === 'relationship' &&
|
||||
potentiallyPopulatedRelationship &&
|
||||
typeof potentiallyPopulatedRelationship === 'object' &&
|
||||
'id' in potentiallyPopulatedRelationship
|
||||
? potentiallyPopulatedRelationship.id
|
||||
: potentiallyPopulatedRelationship
|
||||
|
||||
const groupData = await req.payload.find({
|
||||
collection: collectionSlug,
|
||||
depth: 0,
|
||||
draft: true,
|
||||
fallbackLocale: false,
|
||||
includeLockStatus: true,
|
||||
limit: query?.queryByGroup?.[valueOrRelationshipID]?.limit
|
||||
? Number(query.queryByGroup[valueOrRelationshipID].limit)
|
||||
: undefined,
|
||||
locale: req.locale,
|
||||
overrideAccess: false,
|
||||
page: query?.queryByGroup?.[valueOrRelationshipID]?.page
|
||||
? Number(query.queryByGroup[valueOrRelationshipID].page)
|
||||
: undefined,
|
||||
req,
|
||||
// Note: if we wanted to enable table-by-table sorting, we could use this:
|
||||
// sort: query?.queryByGroup?.[valueOrRelationshipID]?.sort,
|
||||
sort: query?.sort,
|
||||
trash,
|
||||
user,
|
||||
where: {
|
||||
...(whereWithMergedSearch || {}),
|
||||
[groupByFieldPath]: {
|
||||
equals: valueOrRelationshipID,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let heading = valueOrRelationshipID || req.i18n.t('general:noValue')
|
||||
|
||||
if (
|
||||
groupByField?.type === 'relationship' &&
|
||||
potentiallyPopulatedRelationship &&
|
||||
typeof potentiallyPopulatedRelationship === 'object'
|
||||
) {
|
||||
heading =
|
||||
potentiallyPopulatedRelationship[relationshipConfig.admin.useAsTitle || 'id'] ||
|
||||
valueOrRelationshipID
|
||||
}
|
||||
|
||||
if (groupByField.type === 'date') {
|
||||
heading = formatDate({
|
||||
date: String(heading),
|
||||
i18n: req.i18n,
|
||||
pattern: clientConfig.admin.dateFormat,
|
||||
})
|
||||
}
|
||||
|
||||
if (groupData.docs && groupData.docs.length > 0) {
|
||||
const { columnState: newColumnState, Table: NewTable } = renderTable({
|
||||
clientCollectionConfig,
|
||||
collectionConfig,
|
||||
columns,
|
||||
customCellProps,
|
||||
data: groupData,
|
||||
drawerSlug,
|
||||
enableRowSelections,
|
||||
groupByFieldPath,
|
||||
groupByValue: valueOrRelationshipID,
|
||||
heading,
|
||||
i18n: req.i18n,
|
||||
key: `table-${valueOrRelationshipID}`,
|
||||
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
|
||||
payload: req.payload,
|
||||
query,
|
||||
useAsTitle: collectionConfig.admin.useAsTitle,
|
||||
viewType,
|
||||
})
|
||||
|
||||
// Only need to set `columnState` once, using the first table's column state
|
||||
// This will avoid needing to generate column state explicitly for root context that wraps all tables
|
||||
if (!columnState) {
|
||||
columnState = newColumnState
|
||||
}
|
||||
|
||||
if (!Table) {
|
||||
Table = []
|
||||
}
|
||||
|
||||
dataByGroup[valueOrRelationshipID] = groupData
|
||||
;(Table as Array<React.ReactNode>)[i] = NewTable
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
columnState,
|
||||
data,
|
||||
Table,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
import type {
|
||||
AdminViewServerProps,
|
||||
CollectionPreferences,
|
||||
ColumnPreference,
|
||||
ListQuery,
|
||||
ListViewClientProps,
|
||||
ListViewServerPropsOnly,
|
||||
QueryPreset,
|
||||
SanitizedCollectionPermission,
|
||||
} from 'payload'
|
||||
|
||||
import { DefaultListView, HydrateAuthProvider, ListQueryProvider } from '@payloadcms/ui'
|
||||
import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent'
|
||||
import { renderFilters, renderTable, upsertPreferences } from '@payloadcms/ui/rsc'
|
||||
import { notFound } from 'next/navigation.js'
|
||||
import {
|
||||
type AdminViewServerProps,
|
||||
type CollectionPreferences,
|
||||
type Column,
|
||||
type ColumnPreference,
|
||||
type ListQuery,
|
||||
type ListViewClientProps,
|
||||
type ListViewServerPropsOnly,
|
||||
type PaginatedDocs,
|
||||
type QueryPreset,
|
||||
type SanitizedCollectionPermission,
|
||||
} from 'payload'
|
||||
import {
|
||||
combineWhereConstraints,
|
||||
formatAdminURL,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
import React, { Fragment } from 'react'
|
||||
|
||||
import { getDocumentPermissions } from '../Document/getDocumentPermissions.js'
|
||||
import { handleGroupBy } from './handleGroupBy.js'
|
||||
import { renderListViewSlots } from './renderListViewSlots.js'
|
||||
import { resolveAllFilterOptions } from './resolveAllFilterOptions.js'
|
||||
|
||||
@@ -38,6 +40,10 @@ type RenderListViewArgs = {
|
||||
query: ListQuery
|
||||
redirectAfterDelete?: boolean
|
||||
redirectAfterDuplicate?: boolean
|
||||
/**
|
||||
* @experimental This prop is subject to change in future releases.
|
||||
*/
|
||||
trash?: boolean
|
||||
} & AdminViewServerProps
|
||||
|
||||
/**
|
||||
@@ -64,6 +70,8 @@ export const renderListView = async (
|
||||
params,
|
||||
query: queryFromArgs,
|
||||
searchParams,
|
||||
trash,
|
||||
viewType,
|
||||
} = args
|
||||
|
||||
const {
|
||||
@@ -74,7 +82,6 @@ export const renderListView = async (
|
||||
req,
|
||||
req: {
|
||||
i18n,
|
||||
locale,
|
||||
payload,
|
||||
payload: { config },
|
||||
query: queryFromReq,
|
||||
@@ -91,11 +98,17 @@ export const renderListView = async (
|
||||
|
||||
const columnsFromQuery: ColumnPreference[] = transformColumnsToPreferences(query?.columns)
|
||||
|
||||
query.queryByGroup =
|
||||
query?.queryByGroup && typeof query.queryByGroup === 'string'
|
||||
? JSON.parse(query.queryByGroup)
|
||||
: query?.queryByGroup
|
||||
|
||||
const collectionPreferences = await upsertPreferences<CollectionPreferences>({
|
||||
key: `collection-${collectionSlug}`,
|
||||
req,
|
||||
value: {
|
||||
columns: columnsFromQuery,
|
||||
groupBy: query?.groupBy,
|
||||
limit: isNumber(query?.limit) ? Number(query.limit) : undefined,
|
||||
preset: query?.preset,
|
||||
sort: query?.sort as string,
|
||||
@@ -112,6 +125,8 @@ export const renderListView = async (
|
||||
collectionPreferences?.sort ||
|
||||
(typeof collectionConfig.defaultSort === 'string' ? collectionConfig.defaultSort : undefined)
|
||||
|
||||
query.groupBy = collectionPreferences?.groupBy
|
||||
|
||||
query.columns = transformColumnsToSearchParams(collectionPreferences?.columns || [])
|
||||
|
||||
const {
|
||||
@@ -137,6 +152,25 @@ export const renderListView = async (
|
||||
let queryPreset: QueryPreset | undefined
|
||||
let queryPresetPermissions: SanitizedCollectionPermission | undefined
|
||||
|
||||
let whereWithMergedSearch = mergeListSearchAndWhere({
|
||||
collectionConfig,
|
||||
search: typeof query?.search === 'string' ? query.search : undefined,
|
||||
where: combineWhereConstraints([query?.where, baseListFilter]),
|
||||
})
|
||||
|
||||
if (trash === true) {
|
||||
whereWithMergedSearch = {
|
||||
and: [
|
||||
whereWithMergedSearch,
|
||||
{
|
||||
deletedAt: {
|
||||
exists: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (collectionPreferences?.preset) {
|
||||
try {
|
||||
queryPreset = (await payload.findByID({
|
||||
@@ -160,41 +194,82 @@ export const renderListView = async (
|
||||
}
|
||||
}
|
||||
|
||||
const data = await payload.find({
|
||||
collection: collectionSlug,
|
||||
depth: 0,
|
||||
draft: true,
|
||||
fallbackLocale: false,
|
||||
includeLockStatus: true,
|
||||
let Table: React.ReactNode | React.ReactNode[] = null
|
||||
let columnState: Column[] = []
|
||||
let data: PaginatedDocs = {
|
||||
// no results default
|
||||
docs: [],
|
||||
hasNextPage: false,
|
||||
hasPrevPage: false,
|
||||
limit: query.limit,
|
||||
locale,
|
||||
overrideAccess: false,
|
||||
page: query.page,
|
||||
req,
|
||||
sort: query.sort,
|
||||
user,
|
||||
where: mergeListSearchAndWhere({
|
||||
collectionConfig,
|
||||
search: typeof query?.search === 'string' ? query.search : undefined,
|
||||
where: combineWhereConstraints([query?.where, baseListFilter]),
|
||||
}),
|
||||
})
|
||||
nextPage: null,
|
||||
page: 1,
|
||||
pagingCounter: 0,
|
||||
prevPage: null,
|
||||
totalDocs: 0,
|
||||
totalPages: 0,
|
||||
}
|
||||
|
||||
const clientCollectionConfig = clientConfig.collections.find((c) => c.slug === collectionSlug)
|
||||
|
||||
const { columnState, Table } = renderTable({
|
||||
clientCollectionConfig,
|
||||
collectionConfig,
|
||||
columns: collectionPreferences?.columns,
|
||||
customCellProps,
|
||||
docs: data.docs,
|
||||
drawerSlug,
|
||||
enableRowSelections,
|
||||
i18n: req.i18n,
|
||||
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
|
||||
payload,
|
||||
useAsTitle: collectionConfig.admin.useAsTitle,
|
||||
})
|
||||
try {
|
||||
if (collectionConfig.admin.groupBy && query.groupBy) {
|
||||
;({ columnState, data, Table } = await handleGroupBy({
|
||||
clientConfig,
|
||||
collectionConfig,
|
||||
collectionSlug,
|
||||
columns: collectionPreferences?.columns,
|
||||
customCellProps,
|
||||
drawerSlug,
|
||||
enableRowSelections,
|
||||
query,
|
||||
req,
|
||||
trash,
|
||||
user,
|
||||
viewType,
|
||||
where: whereWithMergedSearch,
|
||||
}))
|
||||
} else {
|
||||
data = await req.payload.find({
|
||||
collection: collectionSlug,
|
||||
depth: 0,
|
||||
draft: true,
|
||||
fallbackLocale: false,
|
||||
includeLockStatus: true,
|
||||
limit: query?.limit ? Number(query.limit) : undefined,
|
||||
locale: req.locale,
|
||||
overrideAccess: false,
|
||||
page: query?.page ? Number(query.page) : undefined,
|
||||
req,
|
||||
sort: query?.sort,
|
||||
trash,
|
||||
user,
|
||||
where: whereWithMergedSearch,
|
||||
})
|
||||
;({ columnState, Table } = renderTable({
|
||||
clientCollectionConfig: clientConfig.collections.find((c) => c.slug === collectionSlug),
|
||||
collectionConfig,
|
||||
columns: collectionPreferences?.columns,
|
||||
customCellProps,
|
||||
data,
|
||||
drawerSlug,
|
||||
enableRowSelections,
|
||||
i18n: req.i18n,
|
||||
orderableFieldName: collectionConfig.orderable === true ? '_order' : undefined,
|
||||
payload: req.payload,
|
||||
query,
|
||||
useAsTitle: collectionConfig.admin.useAsTitle,
|
||||
viewType,
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.name !== 'QueryError') {
|
||||
// QueryErrors are expected when a user filters by a field they do not have access to
|
||||
req.payload.logger.error({
|
||||
err,
|
||||
msg: `There was an error fetching the list view data for collection ${collectionSlug}`,
|
||||
})
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const renderedFilters = renderFilters(collectionConfig.fields, req.payload.importMap)
|
||||
|
||||
@@ -214,6 +289,7 @@ export const renderListView = async (
|
||||
})
|
||||
|
||||
const hasCreatePermission = permissions?.collections?.[collectionSlug]?.create
|
||||
const hasDeletePermission = permissions?.collections?.[collectionSlug]?.delete
|
||||
|
||||
// Check if there's a notFound query parameter (document ID that wasn't found)
|
||||
const notFoundDocId = typeof searchParams?.notFound === 'string' ? searchParams.notFound : null
|
||||
@@ -237,6 +313,7 @@ export const renderListView = async (
|
||||
clientProps: {
|
||||
collectionSlug,
|
||||
hasCreatePermission,
|
||||
hasDeletePermission,
|
||||
newDocumentURL,
|
||||
},
|
||||
collectionConfig,
|
||||
@@ -249,6 +326,7 @@ export const renderListView = async (
|
||||
const isInDrawer = Boolean(drawerSlug)
|
||||
|
||||
// Needed to prevent: Only plain objects can be passed to Client Components from Server Components. Objects with toJSON methods are not supported. Convert it manually to a simple value before passing it to props.
|
||||
// Is there a way to avoid this? The `where` object is already seemingly plain, but is not bc it originates from the params.
|
||||
query.where = query?.where ? JSON.parse(JSON.stringify(query?.where || {})) : undefined
|
||||
|
||||
return {
|
||||
@@ -272,6 +350,7 @@ export const renderListView = async (
|
||||
disableQueryPresets,
|
||||
enableRowSelections,
|
||||
hasCreatePermission,
|
||||
hasDeletePermission,
|
||||
listPreferences: collectionPreferences,
|
||||
newDocumentURL,
|
||||
queryPreset,
|
||||
@@ -279,6 +358,7 @@ export const renderListView = async (
|
||||
renderedFilters,
|
||||
resolvedFilterOptions,
|
||||
Table,
|
||||
viewType,
|
||||
} satisfies ListViewClientProps,
|
||||
Component: collectionConfig?.admin?.components?.views?.list?.Component,
|
||||
Fallback: DefaultListView,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { initPage } from '../../utilities/initPage/index.js'
|
||||
import { Account } from '../Account/index.js'
|
||||
import { BrowseByFolder } from '../BrowseByFolder/index.js'
|
||||
import { CollectionFolderView } from '../CollectionFolders/index.js'
|
||||
import { TrashView } from '../CollectionTrash/index.js'
|
||||
import { CreateFirstUserView } from '../CreateFirstUser/index.js'
|
||||
import { Dashboard } from '../Dashboard/index.js'
|
||||
import { Document as DocumentView } from '../Document/index.js'
|
||||
@@ -107,7 +108,7 @@ export const getRouteData = ({
|
||||
searchParams,
|
||||
}
|
||||
|
||||
const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive] = segments
|
||||
const [segmentOne, segmentTwo, segmentThree, segmentFour, segmentFive, segmentSix] = segments
|
||||
|
||||
const isGlobal = segmentOne === 'globals'
|
||||
const isCollection = segmentOne === 'collections'
|
||||
@@ -272,7 +273,50 @@ export const getRouteData = ({
|
||||
viewType = 'verify'
|
||||
} else if (isCollection && matchedCollection) {
|
||||
initPageOptions.routeParams.collection = matchedCollection.slug
|
||||
if (config.folders && segmentThree === config.folders.slug && matchedCollection.folders) {
|
||||
|
||||
if (segmentThree === 'trash' && typeof segmentFour === 'string') {
|
||||
// --> /collections/:collectionSlug/trash/:id (read-only)
|
||||
// --> /collections/:collectionSlug/trash/:id/api
|
||||
// --> /collections/:collectionSlug/trash/:id/preview
|
||||
// --> /collections/:collectionSlug/trash/:id/versions
|
||||
// --> /collections/:collectionSlug/trash/:id/versions/:versionID
|
||||
initPageOptions.routeParams.id = segmentFour
|
||||
initPageOptions.routeParams.versionID = segmentSix
|
||||
|
||||
ViewToRender = {
|
||||
Component: DocumentView,
|
||||
}
|
||||
|
||||
templateClassName = `collection-default-edit`
|
||||
templateType = 'default'
|
||||
|
||||
const viewInfo = getDocumentViewInfo([segmentFive, segmentSix])
|
||||
viewType = viewInfo.viewType
|
||||
documentSubViewType = viewInfo.documentSubViewType
|
||||
|
||||
attachViewActions({
|
||||
collectionOrGlobal: matchedCollection,
|
||||
serverProps,
|
||||
viewKeyArg: documentSubViewType,
|
||||
})
|
||||
} else if (segmentThree === 'trash') {
|
||||
// --> /collections/:collectionSlug/trash
|
||||
ViewToRender = {
|
||||
Component: TrashView,
|
||||
}
|
||||
|
||||
templateClassName = `${segmentTwo}-trash`
|
||||
templateType = 'default'
|
||||
viewType = 'trash'
|
||||
|
||||
serverProps.viewActions = serverProps.viewActions.concat(
|
||||
matchedCollection.admin.components?.views?.list?.actions ?? [],
|
||||
)
|
||||
} else if (
|
||||
config.folders &&
|
||||
segmentThree === config.folders.slug &&
|
||||
matchedCollection.folders
|
||||
) {
|
||||
// Collection Folder Views
|
||||
// --> /collections/:collectionSlug/:folderCollectionSlug
|
||||
// --> /collections/:collectionSlug/:folderCollectionSlug/:folderID
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getNextRequestI18n } from '../../utilities/getNextRequestI18n.js'
|
||||
import { generateAccountViewMetadata } from '../Account/metadata.js'
|
||||
import { generateBrowseByFolderMetadata } from '../BrowseByFolder/metadata.js'
|
||||
import { generateCollectionFolderMetadata } from '../CollectionFolders/metadata.js'
|
||||
import { generateCollectionTrashMetadata } from '../CollectionTrash/metadata.js'
|
||||
import { generateCreateFirstUserViewMetadata } from '../CreateFirstUser/metadata.js'
|
||||
import { generateDashboardViewMetadata } from '../Dashboard/metadata.js'
|
||||
import { generateDocumentViewMetadata } from '../Document/metadata.js'
|
||||
@@ -129,7 +130,16 @@ export const generatePageMetadata = async ({
|
||||
// --> /:collectionSlug/verify/:token
|
||||
meta = await generateVerifyViewMetadata({ config, i18n })
|
||||
} else if (isCollection) {
|
||||
if (config.folders && segmentThree === config.folders.slug) {
|
||||
if (segmentThree === 'trash' && segments.length === 3 && collectionConfig) {
|
||||
// Collection Trash Views
|
||||
// --> /collections/:collectionSlug/trash
|
||||
meta = await generateCollectionTrashMetadata({
|
||||
collectionConfig,
|
||||
config,
|
||||
i18n,
|
||||
params,
|
||||
})
|
||||
} else if (config.folders && segmentThree === config.folders.slug) {
|
||||
if (folderCollectionSlugs.includes(collectionConfig.slug)) {
|
||||
// Collection Folder Views
|
||||
// --> /collections/:collectionSlug/:folderCollectionSlug
|
||||
@@ -147,6 +157,7 @@ export const generatePageMetadata = async ({
|
||||
// --> /collections/:collectionSlug/:id/versions
|
||||
// --> /collections/:collectionSlug/:id/versions/:version
|
||||
// --> /collections/:collectionSlug/:id/api
|
||||
// --> /collections/:collectionSlug/trash/:id
|
||||
meta = await generateDocumentViewMetadata({ collectionConfig, config, i18n, params })
|
||||
}
|
||||
} else if (isGlobal) {
|
||||
|
||||
@@ -12,13 +12,15 @@ export const SetStepNav: React.FC<{
|
||||
readonly collectionConfig?: ClientCollectionConfig
|
||||
readonly globalConfig?: ClientGlobalConfig
|
||||
readonly id?: number | string
|
||||
readonly isTrashed?: boolean
|
||||
versionToCreatedAtFormatted?: string
|
||||
versionToID?: string
|
||||
versionToUseAsTitle?: string
|
||||
versionToUseAsTitle?: Record<string, string> | string
|
||||
}> = ({
|
||||
id,
|
||||
collectionConfig,
|
||||
globalConfig,
|
||||
isTrashed,
|
||||
versionToCreatedAtFormatted,
|
||||
versionToID,
|
||||
versionToUseAsTitle,
|
||||
@@ -52,10 +54,14 @@ export const SetStepNav: React.FC<{
|
||||
? versionToUseAsTitle?.[locale.code] || docLabel
|
||||
: versionToUseAsTitle
|
||||
} else if (useAsTitle === 'id') {
|
||||
docLabel = versionToID
|
||||
docLabel = String(id)
|
||||
}
|
||||
|
||||
setStepNav([
|
||||
const docBasePath: `/${string}` = isTrashed
|
||||
? `/collections/${collectionSlug}/trash/${id}`
|
||||
: `/collections/${collectionSlug}/${id}`
|
||||
|
||||
const nav = [
|
||||
{
|
||||
label: getTranslation(pluralLabel, i18n),
|
||||
url: formatAdminURL({
|
||||
@@ -63,24 +69,40 @@ export const SetStepNav: React.FC<{
|
||||
path: `/collections/${collectionSlug}`,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
if (isTrashed) {
|
||||
nav.push({
|
||||
label: t('general:trash'),
|
||||
url: formatAdminURL({
|
||||
adminRoute,
|
||||
path: `/collections/${collectionSlug}/trash`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
nav.push(
|
||||
{
|
||||
label: docLabel,
|
||||
url: formatAdminURL({
|
||||
adminRoute,
|
||||
path: `/collections/${collectionSlug}/${id}`,
|
||||
path: docBasePath,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Versions',
|
||||
url: formatAdminURL({
|
||||
adminRoute,
|
||||
path: `/collections/${collectionSlug}/${id}/versions`,
|
||||
path: `${docBasePath}/versions`,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: versionToCreatedAtFormatted,
|
||||
url: undefined,
|
||||
},
|
||||
])
|
||||
)
|
||||
|
||||
setStepNav(nav)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,6 +133,7 @@ export const SetStepNav: React.FC<{
|
||||
config,
|
||||
setStepNav,
|
||||
id,
|
||||
isTrashed,
|
||||
locale,
|
||||
t,
|
||||
i18n,
|
||||
|
||||
@@ -67,7 +67,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
|
||||
}
|
||||
}, [code, config.localization, selectedLocalesFromProps])
|
||||
|
||||
const { id: originalDocID, collectionSlug, globalSlug } = useDocumentInfo()
|
||||
const { id: originalDocID, collectionSlug, globalSlug, isTrashed } = useDocumentInfo()
|
||||
const { startRouteTransition } = useRouteTransition()
|
||||
|
||||
const { collectionConfig, globalConfig } = useMemo(() => {
|
||||
@@ -252,7 +252,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
|
||||
</div>
|
||||
<div className={`${baseClass}__version-to-version`}>
|
||||
{VersionToCreatedAtLabel}
|
||||
{canUpdate && (
|
||||
{canUpdate && !isTrashed && (
|
||||
<Restore
|
||||
className={`${baseClass}__restore`}
|
||||
collectionConfig={collectionConfig}
|
||||
@@ -272,6 +272,7 @@ export const DefaultVersionView: React.FC<DefaultVersionsViewProps> = ({
|
||||
collectionConfig={collectionConfig}
|
||||
globalConfig={globalConfig}
|
||||
id={originalDocID}
|
||||
isTrashed={isTrashed}
|
||||
versionToCreatedAtFormatted={versionToCreatedAtFormatted}
|
||||
versionToID={versionToID}
|
||||
versionToUseAsTitle={versionToUseAsTitle}
|
||||
|
||||
@@ -18,12 +18,12 @@ export const generateLabelFromValue = ({
|
||||
value: PopulatedRelationshipValue
|
||||
}): string => {
|
||||
let relatedDoc: TypeWithID
|
||||
let relationTo: string = field.relationTo as string
|
||||
let valueToReturn: string = ''
|
||||
|
||||
const relationTo: string = 'relationTo' in value ? value.relationTo : (field.relationTo as string)
|
||||
|
||||
if (typeof value === 'object' && 'relationTo' in value) {
|
||||
relatedDoc = value.value
|
||||
relationTo = value.relationTo
|
||||
} else {
|
||||
// Non-polymorphic relationship
|
||||
relatedDoc = value
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Drawer,
|
||||
LoadingOverlay,
|
||||
toast,
|
||||
useDocumentInfo,
|
||||
useEditDepth,
|
||||
useModal,
|
||||
useServerFunctions,
|
||||
@@ -30,6 +31,7 @@ export const VersionDrawerContent: React.FC<{
|
||||
globalSlug?: string
|
||||
}> = (props) => {
|
||||
const { collectionSlug, docID, drawerSlug, globalSlug } = props
|
||||
const { isTrashed } = useDocumentInfo()
|
||||
const { closeModal } = useModal()
|
||||
const searchParams = useSearchParams()
|
||||
const prevSearchParams = useRef(searchParams)
|
||||
@@ -58,6 +60,7 @@ export const VersionDrawerContent: React.FC<{
|
||||
segments: [
|
||||
isGlobal ? 'globals' : 'collections',
|
||||
entitySlug,
|
||||
...(isTrashed ? ['trash'] : []),
|
||||
isGlobal ? undefined : String(docID),
|
||||
'versions',
|
||||
].filter(Boolean),
|
||||
@@ -84,7 +87,16 @@ export const VersionDrawerContent: React.FC<{
|
||||
|
||||
void fetchDocumentView()
|
||||
},
|
||||
[closeModal, collectionSlug, globalSlug, drawerSlug, renderDocument, searchParams, t],
|
||||
[
|
||||
closeModal,
|
||||
collectionSlug,
|
||||
drawerSlug,
|
||||
globalSlug,
|
||||
isTrashed,
|
||||
renderDocument,
|
||||
searchParams,
|
||||
t,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -411,6 +411,11 @@ export async function VersionView(props: DocumentViewServerProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const useAsTitleFieldName = collectionConfig?.admin?.useAsTitle || 'id'
|
||||
const versionToUseAsTitle =
|
||||
useAsTitleFieldName === 'id'
|
||||
? String(versionTo.parent)
|
||||
: versionTo.version?.[useAsTitleFieldName]
|
||||
return (
|
||||
<DefaultVersionView
|
||||
canUpdate={docPermissions?.update}
|
||||
@@ -425,7 +430,7 @@ export async function VersionView(props: DocumentViewServerProps) {
|
||||
VersionToCreatedAtLabel={formatPill({ doc: versionTo, labelStyle: 'pill' })}
|
||||
versionToID={versionTo.id}
|
||||
versionToStatus={versionTo.version?._status}
|
||||
versionToUseAsTitle={versionTo[collectionConfig?.admin?.useAsTitle || 'id']}
|
||||
versionToUseAsTitle={versionToUseAsTitle}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const buildVersionColumns = ({
|
||||
docs,
|
||||
globalConfig,
|
||||
i18n: { t },
|
||||
isTrashed,
|
||||
latestDraftVersion,
|
||||
}: {
|
||||
collectionConfig?: SanitizedCollectionConfig
|
||||
@@ -35,6 +36,7 @@ export const buildVersionColumns = ({
|
||||
docs: PaginatedDocs<TypeWithVersion<any>>['docs']
|
||||
globalConfig?: SanitizedGlobalConfig
|
||||
i18n: I18n
|
||||
isTrashed?: boolean
|
||||
latestDraftVersion?: {
|
||||
id: number | string
|
||||
updatedAt: string
|
||||
@@ -59,6 +61,7 @@ export const buildVersionColumns = ({
|
||||
collectionSlug={collectionConfig?.slug}
|
||||
docID={docID}
|
||||
globalSlug={globalConfig?.slug}
|
||||
isTrashed={isTrashed}
|
||||
key={i}
|
||||
rowData={{
|
||||
id: doc.id,
|
||||
|
||||
@@ -8,6 +8,7 @@ export type CreatedAtCellProps = {
|
||||
collectionSlug?: string
|
||||
docID?: number | string
|
||||
globalSlug?: string
|
||||
isTrashed?: boolean
|
||||
rowData?: {
|
||||
id: number | string
|
||||
updatedAt: Date | number | string
|
||||
@@ -18,6 +19,7 @@ export const CreatedAtCell: React.FC<CreatedAtCellProps> = ({
|
||||
collectionSlug,
|
||||
docID,
|
||||
globalSlug,
|
||||
isTrashed,
|
||||
rowData: { id, updatedAt } = {},
|
||||
}) => {
|
||||
const {
|
||||
@@ -29,12 +31,14 @@ export const CreatedAtCell: React.FC<CreatedAtCellProps> = ({
|
||||
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
const trashedDocPrefix = isTrashed ? 'trash/' : ''
|
||||
|
||||
let to: string
|
||||
|
||||
if (collectionSlug) {
|
||||
to = formatAdminURL({
|
||||
adminRoute,
|
||||
path: `/collections/${collectionSlug}/${docID}/versions/${id}`,
|
||||
path: `/collections/${collectionSlug}/${trashedDocPrefix}${docID}/versions/${id}`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export async function VersionsView(props: DocumentViewServerProps) {
|
||||
user,
|
||||
},
|
||||
},
|
||||
routeSegments: segments,
|
||||
searchParams: { limit, page, sort },
|
||||
versions: { disableGutter = false, useVersionDrawerCreatedAtCell = false } = {},
|
||||
} = props
|
||||
@@ -36,6 +37,8 @@ export async function VersionsView(props: DocumentViewServerProps) {
|
||||
const collectionSlug = collectionConfig?.slug
|
||||
const globalSlug = globalConfig?.slug
|
||||
|
||||
const isTrashed = segments[2] === 'trash'
|
||||
|
||||
const {
|
||||
localization,
|
||||
routes: { api: apiRoute },
|
||||
@@ -124,6 +127,7 @@ export async function VersionsView(props: DocumentViewServerProps) {
|
||||
docs: versionsData?.docs,
|
||||
globalConfig,
|
||||
i18n,
|
||||
isTrashed,
|
||||
latestDraftVersion,
|
||||
})
|
||||
|
||||
@@ -140,6 +144,7 @@ export async function VersionsView(props: DocumentViewServerProps) {
|
||||
collectionSlug={collectionSlug}
|
||||
globalSlug={globalSlug}
|
||||
id={id}
|
||||
isTrashed={isTrashed}
|
||||
pluralLabel={pluralLabel}
|
||||
useAsTitle={collectionConfig?.admin?.useAsTitle || globalSlug}
|
||||
view={i18n.t('version:versions')}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@payloadcms/payload-cloud",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "The official Payload Cloud plugin",
|
||||
"homepage": "https://payloadcms.com",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload",
|
||||
"version": "3.48.0",
|
||||
"version": "3.49.1",
|
||||
"description": "Node, React, Headless CMS and Application Framework built on Next.js",
|
||||
"keywords": [
|
||||
"admin panel",
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
UploadFieldClient,
|
||||
} from '../../fields/config/types.js'
|
||||
import type { Payload } from '../../types/index.js'
|
||||
import type { ViewTypes } from '../types.js'
|
||||
|
||||
export type RowData = Record<string, any>
|
||||
|
||||
@@ -82,6 +83,7 @@ export type DefaultCellComponentProps<
|
||||
rowData: RowData
|
||||
}) => void
|
||||
rowData: RowData
|
||||
viewType?: ViewTypes
|
||||
}
|
||||
|
||||
export type DefaultServerCellComponentProps<
|
||||
|
||||
@@ -68,6 +68,9 @@ export type FieldPaths = {
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: This should be renamed to `FieldComponentServerProps` or similar
|
||||
*/
|
||||
export type ServerComponentProps = {
|
||||
clientField: ClientFieldWithOptionalType
|
||||
clientFieldSchemaMap: ClientFieldSchemaMap
|
||||
|
||||
@@ -113,6 +113,7 @@ export type BuildFormStateArgs = {
|
||||
*/
|
||||
mockRSCs?: boolean
|
||||
operation?: 'create' | 'update'
|
||||
readOnly?: boolean
|
||||
/*
|
||||
If true, will render field components within their state object
|
||||
*/
|
||||
|
||||
@@ -45,9 +45,15 @@ export type ListQuery = {
|
||||
* Use `transformColumnsToPreferences` and `transformColumnsToSearchParams` to convert it back and forth
|
||||
*/
|
||||
columns?: ColumnsFromURL
|
||||
/*
|
||||
* A string representing the field to group by, e.g. `category`
|
||||
* A leading hyphen represents descending order, e.g. `-category`
|
||||
*/
|
||||
groupBy?: string
|
||||
limit?: number
|
||||
page?: number
|
||||
preset?: number | string
|
||||
queryByGroup?: Record<string, ListQuery>
|
||||
/*
|
||||
When provided, is automatically injected into the `where` object
|
||||
*/
|
||||
@@ -59,6 +65,10 @@ export type ListQuery = {
|
||||
export type BuildTableStateArgs = {
|
||||
collectionSlug: string | string[]
|
||||
columns?: ColumnPreference[]
|
||||
data?: PaginatedDocs
|
||||
/**
|
||||
* @deprecated Use `data` instead
|
||||
*/
|
||||
docs?: PaginatedDocs['docs']
|
||||
enableRowSelections?: boolean
|
||||
orderableFieldName: string
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SanitizedPermissions } from '../../auth/types.js'
|
||||
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
|
||||
import type { PayloadComponent, SanitizedConfig, ServerProps } from '../../config/types.js'
|
||||
import type { SanitizedGlobalConfig } from '../../globals/config/types.js'
|
||||
import type { PayloadRequest } from '../../types/index.js'
|
||||
import type { Data, DocumentSlots, FormState } from '../types.js'
|
||||
import type { InitPageResult, ViewTypes } from './index.js'
|
||||
|
||||
@@ -50,6 +51,7 @@ export type DocumentTabServerPropsOnly = {
|
||||
readonly collectionConfig?: SanitizedCollectionConfig
|
||||
readonly globalConfig?: SanitizedGlobalConfig
|
||||
readonly permissions: SanitizedPermissions
|
||||
readonly req: PayloadRequest
|
||||
} & ServerProps
|
||||
|
||||
export type DocumentTabClientProps = {
|
||||
@@ -60,9 +62,13 @@ export type DocumentTabServerProps = DocumentTabClientProps & DocumentTabServerP
|
||||
|
||||
export type DocumentTabCondition = (args: {
|
||||
collectionConfig: SanitizedCollectionConfig
|
||||
/**
|
||||
* @deprecated: Use `req.payload.config` instead. This will be removed in v4.
|
||||
*/
|
||||
config: SanitizedConfig
|
||||
globalConfig: SanitizedGlobalConfig
|
||||
permissions: SanitizedPermissions
|
||||
req: PayloadRequest
|
||||
}) => boolean
|
||||
|
||||
// Everything is optional because we merge in the defaults
|
||||
|
||||
@@ -53,6 +53,7 @@ export type AdminViewServerPropsOnly = {
|
||||
readonly redirectAfterCreate?: boolean
|
||||
readonly redirectAfterDelete?: boolean
|
||||
readonly redirectAfterDuplicate?: boolean
|
||||
readonly redirectAfterRestore?: boolean
|
||||
} & ServerProps
|
||||
|
||||
export type AdminViewServerProps = AdminViewClientProps & AdminViewServerPropsOnly
|
||||
@@ -92,6 +93,7 @@ export type ViewTypes =
|
||||
| 'folders'
|
||||
| 'list'
|
||||
| 'reset'
|
||||
| 'trash'
|
||||
| 'verify'
|
||||
| 'version'
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { CollectionPreferences } from '../../preferences/types.js'
|
||||
import type { QueryPreset } from '../../query-presets/types.js'
|
||||
import type { ResolvedFilterOptions } from '../../types/index.js'
|
||||
import type { Column } from '../elements/Table.js'
|
||||
import type { Data } from '../types.js'
|
||||
import type { Data, ViewTypes } from '../types.js'
|
||||
|
||||
export type ListViewSlots = {
|
||||
AfterList?: React.ReactNode
|
||||
@@ -17,7 +17,7 @@ export type ListViewSlots = {
|
||||
BeforeListTable?: React.ReactNode
|
||||
Description?: React.ReactNode
|
||||
listMenuItems?: React.ReactNode[]
|
||||
Table: React.ReactNode
|
||||
Table: React.ReactNode | React.ReactNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +45,7 @@ export type ListViewClientProps = {
|
||||
disableQueryPresets?: boolean
|
||||
enableRowSelections?: boolean
|
||||
hasCreatePermission: boolean
|
||||
hasDeletePermission?: boolean
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@@ -58,11 +59,13 @@ export type ListViewClientProps = {
|
||||
queryPresetPermissions?: SanitizedCollectionPermission
|
||||
renderedFilters?: Map<string, React.ReactNode>
|
||||
resolvedFilterOptions?: Map<string, ResolvedFilterOptions>
|
||||
viewType: ViewTypes
|
||||
} & ListViewSlots
|
||||
|
||||
export type ListViewSlotSharedClientProps = {
|
||||
collectionSlug: SanitizedCollectionConfig['slug']
|
||||
hasCreatePermission: boolean
|
||||
hasDeletePermission?: boolean
|
||||
newDocumentURL: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const isUserLocked = (date: number): boolean => {
|
||||
export const isUserLocked = (date: Date): boolean => {
|
||||
if (!date) {
|
||||
return false
|
||||
}
|
||||
return date > Date.now()
|
||||
return date.getTime() > Date.now()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { PayloadRequest, Where } from '../../types/index.js'
|
||||
import { buildAfterOperation } from '../../collections/operations/utils.js'
|
||||
import { APIError } from '../../errors/index.js'
|
||||
import { Forbidden } from '../../index.js'
|
||||
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
|
||||
import { commitTransaction } from '../../utilities/commitTransaction.js'
|
||||
import { formatAdminURL } from '../../utilities/formatAdminURL.js'
|
||||
import { initTransaction } from '../../utilities/initTransaction.js'
|
||||
@@ -123,6 +124,13 @@ export const forgotPasswordOperation = async <TSlug extends CollectionSlug>(
|
||||
}
|
||||
}
|
||||
|
||||
// Exclude trashed users unless `trash: true`
|
||||
whereConstraint = appendNonTrashedFilter({
|
||||
enableTrash: collectionConfig.trash,
|
||||
trash: false,
|
||||
where: whereConstraint,
|
||||
})
|
||||
|
||||
let user = await payload.db.findOne<UserDoc>({
|
||||
collection: collectionConfig.slug,
|
||||
req,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { PayloadRequest } from '../../types/index.js'
|
||||
import type { PayloadRequest, Where } from '../../types/index.js'
|
||||
|
||||
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
|
||||
|
||||
export const initOperation = async (args: {
|
||||
collection: string
|
||||
@@ -6,9 +8,19 @@ export const initOperation = async (args: {
|
||||
}): Promise<boolean> => {
|
||||
const { collection: slug, req } = args
|
||||
|
||||
const collectionConfig = req.payload.config.collections?.find((c) => c.slug === slug)
|
||||
|
||||
// Exclude trashed documents unless `trash: true`
|
||||
const where: Where = appendNonTrashedFilter({
|
||||
enableTrash: Boolean(collectionConfig?.trash),
|
||||
trash: false,
|
||||
where: {},
|
||||
})
|
||||
|
||||
const doc = await req.payload.db.findOne({
|
||||
collection: slug,
|
||||
req,
|
||||
where,
|
||||
})
|
||||
|
||||
return !!doc
|
||||
|
||||
@@ -22,6 +22,7 @@ export type Options<TSlug extends CollectionSlug> = {
|
||||
overrideAccess?: boolean
|
||||
req?: Partial<PayloadRequest>
|
||||
showHiddenFields?: boolean
|
||||
trash?: boolean
|
||||
}
|
||||
|
||||
export async function loginLocal<TSlug extends CollectionSlug>(
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../../errors/index.js'
|
||||
import { afterRead } from '../../fields/hooks/afterRead/index.js'
|
||||
import { Forbidden } from '../../index.js'
|
||||
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
|
||||
import { killTransaction } from '../../utilities/killTransaction.js'
|
||||
import { sanitizeInternalFields } from '../../utilities/sanitizeInternalFields.js'
|
||||
import { getFieldsToSign } from '../getFieldsToSign.js'
|
||||
@@ -49,6 +50,11 @@ type CheckLoginPermissionArgs = {
|
||||
user: any
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an error if the user is locked or does not exist.
|
||||
* This does not check the login attempts, only the lock status. Whoever increments login attempts
|
||||
* is responsible for locking the user properly, not whoever checks the login permission.
|
||||
*/
|
||||
export const checkLoginPermission = ({
|
||||
loggingInWithUsername,
|
||||
req,
|
||||
@@ -58,7 +64,7 @@ export const checkLoginPermission = ({
|
||||
throw new AuthenticationError(req.t, Boolean(loggingInWithUsername))
|
||||
}
|
||||
|
||||
if (isUserLocked(new Date(user.lockUntil).getTime())) {
|
||||
if (isUserLocked(new Date(user.lockUntil))) {
|
||||
throw new LockedAuth(req.t)
|
||||
}
|
||||
}
|
||||
@@ -198,11 +204,18 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
||||
whereConstraint = usernameConstraint
|
||||
}
|
||||
|
||||
let user = await payload.db.findOne<any>({
|
||||
// Exclude trashed users
|
||||
whereConstraint = appendNonTrashedFilter({
|
||||
enableTrash: collectionConfig.trash,
|
||||
trash: false,
|
||||
where: whereConstraint,
|
||||
})
|
||||
|
||||
let user = (await payload.db.findOne<TypedUser>({
|
||||
collection: collectionConfig.slug,
|
||||
req,
|
||||
where: whereConstraint,
|
||||
})
|
||||
})) as TypedUser
|
||||
|
||||
checkLoginPermission({
|
||||
loggingInWithUsername: Boolean(canLoginWithUsername && sanitizedUsername),
|
||||
@@ -222,9 +235,16 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
||||
if (maxLoginAttemptsEnabled) {
|
||||
await incrementLoginAttempts({
|
||||
collection: collectionConfig,
|
||||
doc: user,
|
||||
payload: req.payload,
|
||||
req,
|
||||
user,
|
||||
})
|
||||
|
||||
// Re-check login permissions and max attempts after incrementing attempts, in case parallel updates occurred
|
||||
checkLoginPermission({
|
||||
loggingInWithUsername: Boolean(canLoginWithUsername && sanitizedUsername),
|
||||
req,
|
||||
user,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -235,6 +255,30 @@ export const loginOperation = async <TSlug extends CollectionSlug>(
|
||||
throw new UnverifiedEmail({ t: req.t })
|
||||
}
|
||||
|
||||
/*
|
||||
* Correct password accepted - re‑check that the account didn't
|
||||
* get locked by parallel bad attempts in the meantime.
|
||||
*/
|
||||
if (maxLoginAttemptsEnabled) {
|
||||
const { lockUntil, loginAttempts } = (await payload.db.findOne<TypedUser>({
|
||||
collection: collectionConfig.slug,
|
||||
req,
|
||||
select: {
|
||||
lockUntil: true,
|
||||
loginAttempts: true,
|
||||
},
|
||||
where: { id: { equals: user.id } },
|
||||
}))!
|
||||
|
||||
user.lockUntil = lockUntil
|
||||
user.loginAttempts = loginAttempts
|
||||
|
||||
checkLoginPermission({
|
||||
req,
|
||||
user,
|
||||
})
|
||||
}
|
||||
|
||||
const fieldsToSignArgs: Parameters<typeof getFieldsToSign>[0] = {
|
||||
collectionConfig,
|
||||
email: sanitizedEmail!,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Collection } from '../../collections/config/types.js'
|
||||
import type { PayloadRequest } from '../../types/index.js'
|
||||
|
||||
import { APIError } from '../../errors/index.js'
|
||||
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
|
||||
|
||||
export type Arguments = {
|
||||
allSessions?: boolean
|
||||
@@ -39,17 +40,23 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean>
|
||||
}
|
||||
|
||||
if (collectionConfig.auth.disableLocalStrategy !== true && collectionConfig.auth.useSessions) {
|
||||
const where = appendNonTrashedFilter({
|
||||
enableTrash: Boolean(collectionConfig.trash),
|
||||
trash: false,
|
||||
where: {
|
||||
id: {
|
||||
equals: user.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const userWithSessions = await req.payload.db.findOne<{
|
||||
id: number | string
|
||||
sessions: { id: string }[]
|
||||
}>({
|
||||
collection: collectionConfig.slug,
|
||||
req,
|
||||
where: {
|
||||
id: {
|
||||
equals: user.id,
|
||||
},
|
||||
},
|
||||
where,
|
||||
})
|
||||
|
||||
if (!userWithSessions) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import url from 'url'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
|
||||
import type { Collection } from '../../collections/config/types.js'
|
||||
import type { Document, PayloadRequest } from '../../types/index.js'
|
||||
@@ -74,11 +73,10 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
|
||||
const parsedURL = url.parse(args.req.url!)
|
||||
const isGraphQL = parsedURL.pathname === config.routes.graphQL
|
||||
|
||||
const user = await args.req.payload.findByID({
|
||||
id: args.req.user.id,
|
||||
collection: args.req.user.collection,
|
||||
depth: isGraphQL ? 0 : args.collection.config.auth.depth,
|
||||
req: args.req,
|
||||
let user = await req.payload.db.findOne<any>({
|
||||
collection: collectionConfig.slug,
|
||||
req,
|
||||
where: { id: { equals: args.req.user.id } },
|
||||
})
|
||||
|
||||
const sid = args.req.user._sid
|
||||
@@ -88,7 +86,7 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
|
||||
throw new Forbidden(args.req.t)
|
||||
}
|
||||
|
||||
const existingSession = user.sessions.find(({ id }) => id === sid)
|
||||
const existingSession = user.sessions.find(({ id }: { id: number }) => id === sid)
|
||||
|
||||
const now = new Date()
|
||||
const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000
|
||||
@@ -106,6 +104,13 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
|
||||
})
|
||||
}
|
||||
|
||||
user = await req.payload.findByID({
|
||||
id: user.id,
|
||||
collection: collectionConfig.slug,
|
||||
depth: isGraphQL ? 0 : args.collection.config.auth.depth,
|
||||
req: args.req,
|
||||
})
|
||||
|
||||
if (user) {
|
||||
user.collection = args.req.user.collection
|
||||
user._strategy = args.req.user._strategy
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CollectionSlug } from '../../index.js'
|
||||
import type { PayloadRequest, SelectType } from '../../types/index.js'
|
||||
|
||||
import { Forbidden } from '../../errors/index.js'
|
||||
import { appendNonTrashedFilter } from '../../utilities/appendNonTrashedFilter.js'
|
||||
import { commitTransaction } from '../../utilities/commitTransaction.js'
|
||||
import { initTransaction } from '../../utilities/initTransaction.js'
|
||||
import { killTransaction } from '../../utilities/killTransaction.js'
|
||||
@@ -57,9 +58,16 @@ export const registerFirstUserOperation = async <TSlug extends CollectionSlug>(
|
||||
req,
|
||||
})
|
||||
|
||||
const where = appendNonTrashedFilter({
|
||||
enableTrash: Boolean(config.trash),
|
||||
trash: false,
|
||||
where: {}, // no initial filter; just exclude trashed docs
|
||||
})
|
||||
|
||||
const doc = await payload.db.findOne({
|
||||
collection: config.slug,
|
||||
req,
|
||||
where,
|
||||
})
|
||||
|
||||
if (doc) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user