wip merge master

This commit is contained in:
Dan Ribbens
2023-06-29 13:27:33 -04:00
parent b4c049c745
commit 682f8ecae4
173 changed files with 9894 additions and 2930 deletions

View File

@@ -1,5 +1,5 @@
name: Bug Report
description: Create a bug report for the Payload CMS
description: Create a bug report for Payload
labels: ["possible-bug"]
body:
- type: markdown

View File

@@ -83,6 +83,6 @@ jobs:
${{ runner.os }}-npm-${{ env.cache-name }}-
${{ runner.os }}-npm-
${{ runner.os }}-
- run: npm install --legacy-peer-deps
- run: npm install
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -1,5 +1,76 @@
## [1.10.2](https://github.com/payloadcms/payload/compare/v1.10.1...v1.10.2) (2023-06-26)
### Bug Fixes
* adjusts swc loader to only exclude non ts/tsx files - [#2888](https://github.com/payloadcms/payload/issues/2888) ([#2907](https://github.com/payloadcms/payload/issues/2907)) ([a2d9ef3](https://github.com/payloadcms/payload/commit/a2d9ef3ca618934df58102a7e02e86dbe0ed63da))
* autosave on localized fields, adds test ([6893231](https://github.com/payloadcms/payload/commit/6893231f85f702189089a6d78d3f3af63aaa0d82))
* broken export of entityToJSONSchema ([#2894](https://github.com/payloadcms/payload/issues/2894)) ([837dccc](https://github.com/payloadcms/payload/commit/837dcccefeffe7bb6e674713b4184c4eb92db8dc))
* correctly scopes data variable within bulk update - [#2901](https://github.com/payloadcms/payload/issues/2901) ([#2904](https://github.com/payloadcms/payload/issues/2904)) ([f627277](https://github.com/payloadcms/payload/commit/f627277479e6a4a847e79f54c545712a7186abb9))
* safely check for tempFilePath when updating media document ([#2899](https://github.com/payloadcms/payload/issues/2899)) ([8206c0f](https://github.com/payloadcms/payload/commit/8206c0fe8be78a5e0f7c8e64996d73d135b1fcc2))
## [1.10.1](https://github.com/payloadcms/payload/compare/v1.10.0...v1.10.1) (2023-06-22)
### Bug Fixes
* conditional fields perf bug - [#2886](https://github.com/payloadcms/payload/issues/2886) ([#2890](https://github.com/payloadcms/payload/issues/2890)) ([b83d788](https://github.com/payloadcms/payload/commit/b83d788d3cfe12f87dcd63a9df20b939a6f4681e))
* cutoff tooltips in relationship field ([#2873](https://github.com/payloadcms/payload/issues/2873)) ([09c6cad](https://github.com/payloadcms/payload/commit/09c6cad3e8462dc3d8b1b6424aafd336c1d7828c))
* Relationship hasMany and filterOptions fails above 10 items ([#2891](https://github.com/payloadcms/payload/issues/2891)) ([8128de6](https://github.com/payloadcms/payload/commit/8128de64dff98fdbcf053faef9de3c3f9a733071))
# [1.10.0](https://github.com/payloadcms/payload/compare/v1.9.5...v1.10.0) (2023-06-20)
### Bug Fixes
* [#2831](https://github.com/payloadcms/payload/issues/2831), persists payloadAPI through local operations that accept req ([85d2467](https://github.com/payloadcms/payload/commit/85d2467d73582a372ee34e3ce93403847a1f0689))
* [#2842](https://github.com/payloadcms/payload/issues/2842), querying number custom ids with in ([116e9ff](https://github.com/payloadcms/payload/commit/116e9ffe81f44c4b40fa578b4a8fe4bb70fd110c))
* default sort with near operator ([#2862](https://github.com/payloadcms/payload/issues/2862)) ([99f3809](https://github.com/payloadcms/payload/commit/99f38098dd4a386437c469becc975ca86c54601f))
* deprecate min/max in exchange for minRows and maxRows for relationship field ([#2826](https://github.com/payloadcms/payload/issues/2826)) ([0d8d7f3](https://github.com/payloadcms/payload/commit/0d8d7f358d390184f6f888d77858b4a145e94214))
* drawer close on backspace ([#2869](https://github.com/payloadcms/payload/issues/2869)) ([a110ba2](https://github.com/payloadcms/payload/commit/a110ba2dc09cd0824a9b1eb8e011604388277bd8))
* drawer fields are read-only if opened from a hasMany relationship ([#2843](https://github.com/payloadcms/payload/issues/2843)) ([542b536](https://github.com/payloadcms/payload/commit/542b5362d3ec8741aff6b1672fab7d2250e7b854))
* fields in relationship drawer not usable [#2815](https://github.com/payloadcms/payload/issues/2815) ([#2870](https://github.com/payloadcms/payload/issues/2870)) ([8626dc6](https://github.com/payloadcms/payload/commit/8626dc6b1a926143e7ba505f3edd924432168675))
* mobile loading overlay width [#2866](https://github.com/payloadcms/payload/issues/2866) ([#2867](https://github.com/payloadcms/payload/issues/2867)) ([ba9d633](https://github.com/payloadcms/payload/commit/ba9d6336acc779cfec0db312c8e2da912ce58cd4))
* near query sorting by distance and pagination ([#2861](https://github.com/payloadcms/payload/issues/2861)) ([1611896](https://github.com/payloadcms/payload/commit/16118960aa6d63f7a429f168ff4305f336b1b1e6))
* relationship field query pagination ([#2871](https://github.com/payloadcms/payload/issues/2871)) ([ce84174](https://github.com/payloadcms/payload/commit/ce84174554d9d828cbaaaa9548e5defc0feb4e2b))
* slow like queries with lots of records ([4dd703a](https://github.com/payloadcms/payload/commit/4dd703a6bff0ab7d06af234baa975553bd62f176))
### Features
* automatically redirect a user back to their originally requested URL after login ([#2838](https://github.com/payloadcms/payload/issues/2838)) ([e910688](https://github.com/payloadcms/payload/commit/e9106882f721d43bcc05a1690bda7754b450404e))
* hasMany for number field ([#2517](https://github.com/payloadcms/payload/issues/2517)) ([8f086e3](https://github.com/payloadcms/payload/commit/8f086e315cb30be9d399fd3022c16952fb81cb2e)), closes [#2812](https://github.com/payloadcms/payload/issues/2812) [#2821](https://github.com/payloadcms/payload/issues/2821) [#2823](https://github.com/payloadcms/payload/issues/2823) [#2824](https://github.com/payloadcms/payload/issues/2824) [#2814](https://github.com/payloadcms/payload/issues/2814) [#2793](https://github.com/payloadcms/payload/issues/2793) [#2835](https://github.com/payloadcms/payload/issues/2835)
* optimizes conditional logic performance ([967f217](https://github.com/payloadcms/payload/commit/967f21734600de1fec8c1227a354ef5a417e54c5))
## [1.9.5](https://github.com/payloadcms/payload/compare/v1.9.4...v1.9.5) (2023-06-16)
## [1.9.4](https://github.com/payloadcms/payload/compare/v1.9.3...v1.9.4) (2023-06-16)
### Bug Fixes
* incorrectly return totalDocs=1 instead of the correct count when pagination=false ([2e73938](https://github.com/payloadcms/payload/commit/2e7393853447d2da41ddef79f73e9026719a674b))
## [1.9.3](https://github.com/payloadcms/payload/compare/v1.9.2...v1.9.3) (2023-06-16)
### Bug Fixes
* adds custom property to ui field in joi validation ([#2835](https://github.com/payloadcms/payload/issues/2835)) ([56d7745](https://github.com/payloadcms/payload/commit/56d7745139e31c5d42c5191477f409f12589a952))
* ensures relations to object ids can be queried on ([c3d6e1b](https://github.com/payloadcms/payload/commit/c3d6e1b490a69f0aadb00e54e46a8774732e6658))
## [1.9.2](https://github.com/payloadcms/payload/compare/v1.9.1...v1.9.2) (2023-06-14)
### Bug Fixes
* [#2821](https://github.com/payloadcms/payload/issues/2821) i18n ui field label ([#2823](https://github.com/payloadcms/payload/issues/2823)) ([63cd7fb](https://github.com/payloadcms/payload/commit/63cd7fbd0c91bbf5120e95fd33388a38e593b341))
* adds missing dark-mode styles for version differences view ([#2812](https://github.com/payloadcms/payload/issues/2812)) ([346a48f](https://github.com/payloadcms/payload/commit/346a48f871e09a3d5e25b7ff9e45689a104b0f9f))
* sanitize reset password result - [#2805](https://github.com/payloadcms/payload/issues/2805) ([#2808](https://github.com/payloadcms/payload/issues/2808)) ([46a5f41](https://github.com/payloadcms/payload/commit/46a5f417217313b049f4b412abb3319634f27262))
* user can be created without having to specify an email - [#2801](https://github.com/payloadcms/payload/issues/2801) ([abe3852](https://github.com/payloadcms/payload/commit/abe38520aaaefdfaea4c47130eea04a42a82627b))
## [1.9.1](https://github.com/payloadcms/payload/compare/v1.9.0...v1.9.1) (2023-06-09)

View File

@@ -2,7 +2,7 @@
<a href="https://payloadcms.com">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-light.svg">
<img width="350" alt="payload cms logo" src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg">
<img width="350" alt="Payload Logo" src="https://raw.githubusercontent.com/payloadcms/payload/master/src/admin/assets/images/payload-logo-dark.svg">
</picture>
</a>
</p>
@@ -41,7 +41,7 @@
</a>
&nbsp;
<a href="https://twitter.com/payloadcms">
<img src="https://img.shields.io/badge/follow-payloadcms-1DA1F2?logo=twitter&style=flat-square" alt="Payload CMS Twitter" />
<img src="https://img.shields.io/badge/follow-payloadcms-1DA1F2?logo=twitter&style=flat-square" alt="Payload Twitter" />
</a>
</p>

View File

@@ -1,6 +1,6 @@
# Contributing to Payload CMS
# Contributing to Payload
Below you'll find a set of guidelines for how to contribute to Payload CMS.
Below you'll find a set of guidelines for how to contribute to Payload.
## Opening issues

View File

@@ -17,7 +17,7 @@ To enable Authentication on a collection, define an `auth` property and set it t
| **`useAPIKey`** | Payload Authentication provides for API keys to be set on each user within an Authentication-enabled Collection. [More](/docs/authentication/config#api-keys) |
| **`tokenExpiration`** | How long (in seconds) to keep the user logged in. JWTs and HTTP-only cookies will both expire at the same time. |
| **`maxLoginAttempts`** | Only allow a user to attempt logging in X amount of times. Automatically locks out a user from authenticating if this limit is passed. Set to `0` to disable. |
| **`lockTime`** | Set the time (in milliseconds) that a user should be locked out if they fail authentication more times than `maxLoginAttempts` allows for. |
| **`lockTime`** | Set the time (in milliseconds) that a user should be locked out if they fail authentication more times than `maxLoginAttempts` allows for. |
| **`depth`** | How many levels deep a `user` document should be populated when creating the JWT and binding the `user` to the express `req`. Defaults to `0` and should only be modified if absolutely necessary, as this will affect performance. |
| **`cookies`** | Set cookie options, including `secure`, `sameSite`, and `domain`. For advanced users. |
| **`forgotPassword`** | Customize the way that the `forgotPassword` operation functions. [More](/docs/authentication/config#forgot-password) |
@@ -29,10 +29,12 @@ To enable Authentication on a collection, define an `auth` property and set it t
To integrate with third-party APIs or services, you might need the ability to generate API keys that can be used to identify as a certain user within Payload.
In Payload, users are essentially documents within a collection. Just like you can authenticate as a user with an email and password, which is considered as our default local auth strategy, you can also authenticate as a user with an API key. API keys are generated on a user-by-user basis, similar to email and passwords, and are meant to represent a single user.
For example, if you have a third-party service or external app that needs to be able to perform protected actions at its discretion, you have two options:
1. Create a user for the third-party app, and log in each time to receive a token before you attempt to access any protected actions
1. Enable API key support for the Collection, where you can generate a non-expiring API key per user in the collection
1. Enable API key support for the Collection, where you can generate a non-expiring API key per user in the collection. This is particularly useful as you can create a "user" that reflects an integration with a specific external service and assign a "role" or specific access only needed by that service/integration. Alternatively, you could create a "super admin" user and assign an API key to that user so that any requests made with that API key are considered as being made by that super user.
Technically, both of these options will work for third-party integrations but the second option with API key is simpler, because it reduces the amount of work that your integrations need to do to be authenticated properly.
@@ -45,7 +47,7 @@ To enable API keys on a collection, set the `useAPIKey` auth option to `true`. F
#### Authenticating via API Key
To authenticate REST or GraphQL API requests using an API key, set the `Authorization` header. The header is case-sensitive and needs the slug of the `auth.useAPIKey` enabled collection, then " API-Key ", followed by the `apiKey` that has been assigned. Payload's built-in middleware will then assign the user document to `req.user` and handle requests with the proper access control.
To authenticate REST or GraphQL API requests using an API key, set the `Authorization` header. The header is case-sensitive and needs the slug of the `auth.useAPIKey` enabled collection, then " API-Key ", followed by the `apiKey` that has been assigned. Payload's built-in middleware will then assign the user document to `req.user` and handle requests with the proper access control. By doing this, Payload recognizes the request being made as a request by the user associated with that API key.
**For example, using Fetch:**
@@ -59,6 +61,8 @@ const response = await fetch("http://localhost:3000/api/pages", {
});
```
Payload ensures that the same, uniform access control is used across all authentication strategies. This enables you to utilize your existing access control configurations with both API keys and the standard email/password authentication. This consistency can aid in maintaining granular control over your API keys.
### Forgot Password
You can customize how the Forgot Password workflow operates with the following options on the `auth.forgotPassword` property:

View File

@@ -29,28 +29,30 @@ import payload from "payload";
const app = express();
payload.init({
secret: "PAYLOAD_SECRET_KEY",
mongoURL: "mongodb://localhost/payload",
express: app,
});
const start = async () => {
await payload.init({
secret: "PAYLOAD_SECRET_KEY",
mongoURL: "mongodb://localhost/payload",
express: app,
});
const router = express.Router();
const router = express.Router();
// Note: Payload must be initialized before the `payload.authenticate` middleware can be used
router.use(payload.authenticate); // highlight-line
// Note: Payload must be initialized before the `payload.authenticate` middleware can be used
router.use(payload.authenticate); // highlight-line
router.get("/", (req, res) => {
if (req.user) {
return res.send(`Authenticated successfully as ${req.user.email}.`);
}
router.get("/", (req, res) => {
if (req.user) {
return res.send(`Authenticated successfully as ${req.user.email}.`);
}
return res.send("Not authenticated");
});
return res.send("Not authenticated");
});
app.use("/some-route-here", router);
app.use("/some-route-here", router);
app.listen(3000, async () => {
payload.logger.info(`listening on ${3000}...`);
});
app.listen(3000);
};
start();
```

View File

@@ -24,22 +24,22 @@ _Admin panel screenshot of a Blocks field type with Call to Action and Number bl
### Field config
| Option | Description |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | -------------- | ----------------------------------- |
| **`name`** \* | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as the heading in the Admin panel or an object with keys for each language. Auto-generated from name if not defined. |
| **`blocks`** \* | Array of [block configs](/docs/fields/blocks#block-configs) to be made available to this field. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. |
| **`hooks`** | Provide field-level hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
| **`access`** | Provide field-level access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API response or the Admin panel. |
| **`defaultValue`** | Provide an array of block data to be used for this field's default value. [More](/docs/fields/overview#default-values) |
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. If enabled, a separate, localized set of all data within this field will be kept, so there is no need to specify each nested field as `localized`. | | **`required`** | Require this field to have a value. |
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
| **`labels`** | Customize the block row labels appearing in the Admin dashboard. |
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| Option | Description |
|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **`name`** * | To be used as the property name when stored and retrieved from the database. [More](/docs/fields/overview#field-names) |
| **`label`** | Text used as the heading in the Admin panel or an object with keys for each language. Auto-generated from name if not defined. |
| **`blocks`** * | Array of [block configs](/docs/fields/blocks#block-configs) to be made available to this field. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
| **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. |
| **`hooks`** | Provide field-level hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
| **`access`** | Provide field-level access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
| **`hidden`** | Restrict this field's visibility from all APIs entirely. Will still be saved to the database, but will not appear in any API response or the Admin panel. |
| **`defaultValue`** | Provide an array of block data to be used for this field's default value. [More](/docs/fields/overview#default-values) |
| **`localized`** | Enable localization for this field. Requires [localization to be enabled](/docs/configuration/localization) in the Base config. If enabled, a separate, localized set of all data within this field will be kept, so there is no need to specify each nested field as `localized`. |
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
| **`labels`** | Customize the block row labels appearing in the Admin dashboard. |
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-config). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
_\* An asterisk denotes that a property is required._
@@ -93,33 +93,33 @@ The Admin panel provides each block with a `blockName` field which optionally al
`collections/ExampleCollection.js`
```ts
import { Block, CollectionConfig } from "payload/types";
import { Block, CollectionConfig } from 'payload/types';
const QuoteBlock: Block = {
slug: "Quote", // required
imageURL: "https://google.com/path/to/image.jpg",
imageAltText: "A nice thumbnail image to show what this block looks like",
interfaceName: "QuoteBlock", // optional
slug: 'Quote', // required
imageURL: 'https://google.com/path/to/image.jpg',
imageAltText: 'A nice thumbnail image to show what this block looks like',
interfaceName: 'QuoteBlock', // optional
fields: [
// required
{
name: "quoteHeader",
type: "text",
name: 'quoteHeader',
type: 'text',
required: true,
},
{
name: "quoteText",
type: "text",
name: 'quoteText',
type: 'text',
},
],
};
export const ExampleCollection: CollectionConfig = {
slug: "example-collection",
slug: 'example-collection',
fields: [
{
name: "layout", // required
type: "blocks", // required
name: 'layout', // required
type: 'blocks', // required
minRows: 1,
maxRows: 20,
blocks: [
@@ -136,5 +136,5 @@ export const ExampleCollection: CollectionConfig = {
As you build your own Block configs, you might want to store them in separate files but retain typing accordingly. To do so, you can import and use Payload's `Block` type:
```ts
import type { Block } from "payload/types";
import type { Block } from 'payload/types';
```

View File

@@ -18,6 +18,9 @@ keywords: number, fields, config, configuration, documentation, Content Manageme
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`min`** | Minimum value accepted. Used in the default `validation` function. |
| **`max`** | Maximum value accepted. Used in the default `validation` function. |
| **`hasMany`** | Makes this field an ordered array of numbers instead of just a single number. |
| **`minRows`** | Minimum number of numbers in the numbers array, if `hasMany` is set to true. |
| **`maxRows`** | Maximum number of numbers in the numbers array, if `hasMany` is set to true. |
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |
| **`index`** | Build a [MongoDB index](https://docs.mongodb.com/manual/indexes/) for this field to produce faster queries. Set this field to `true` if your users will perform queries on this field's data often. |
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |

View File

@@ -25,8 +25,8 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma
| **`relationTo`** \* | Provide one or many collection `slug`s to be able to assign relationships to. |
| **`filterOptions`** | A query to filter which options appear in the UI and validate against. [More](#filtering-relationship-options). |
| **`hasMany`** | Boolean when, if set to `true`, allows this field to have many relations instead of only one. |
| **`min`** | A number for the fewest allowed items during validation when a value is present. Used with `hasMany`. |
| **`max`** | A number for the most allowed items during validation when a value is present. Used with `hasMany`. |
| **`minRows`** | A number for the fewest allowed items during validation when a value is present. Used with `hasMany`. |
| **`maxRows`** | A number for the most allowed items during validation when a value is present. Used with `hasMany`. |
| **`maxDepth`** | Sets a number limit on iterations of related documents to populate when queried. [Depth](/docs/getting-started/concepts#depth) |
| **`label`** | Text used as a field label in the Admin panel or an object with keys for each language. |
| **`unique`** | Enforce that each entry in the Collection has a unique value for this field. |

View File

@@ -117,7 +117,7 @@ It is also possible to limit the depth for specific `relation` and `upload` fiel
}
```
If you were to query the Posts endpoint at, say, `http://localhost:3000/api/posts?depth=1`, you will retrieve Posts with populations one level deep. A returned result may look like the following:
If you were to query the Posts endpoint at, say, `http://localhost:3000/api/posts?depth=1`, you will retrieve Posts with populations one level deep. This depth parameter can be thought of as N, where N is the number of levels you want to populate. To populate one level further, you would simply specify N+1 as the depth. A returned result may look like the following:
```
// ?depth=1

View File

@@ -8,7 +8,7 @@ keywords: documentation, getting started, guide, Content Management System, cms,
<YouTube
id="In_lFhzmbME"
title="Payload CMS Introduction - Closing the Gap Between Headless CMS and Application Frameworks"
title="Payload Introduction - Closing the Gap Between Headless CMS and Application Frameworks"
/>
<Banner type="success">

View File

@@ -0,0 +1,104 @@
---
title: Vercel Visual Editing
label: Vercel Visual Editing
order: 10
desc: Payload + Vercel Visual Editing allows yours editors to navigate directly from the content rendered on your front-end to the fields in Payload that control it.
keywords: vercel, vercel visual editing, visual editing, content source maps, Content Management System, cms, headless, javascript, node, react, express
---
[Vercel Visual Editing](https://vercel.com/docs/workflow-collaboration/visual-editing) will allow your editors to navigate directly from the content rendered on your front-end to the fields in Payload that control it. This requires no changes to your front-end code and very few changes to your Payload config.
![Versions](/images/docs/vercel-visual-editing.jpg)
<Banner type="warning">
Vercel Visual Editing is an enterprise-only feature and only available for
deployments hosted on Vercel. If you are an existing enterprise customer,
[contact our sales team](https://payloadcms.com/for-enterprise) for help with
your integration.
</Banner>
### How it works
To power Vercel Visual Editing, Payload embeds Content Source Maps into its API responses. Content Source Maps are invisible, encoded JSON values that include a link back to the field in the CMS that generated the content. When rendered on the page, Vercel detects and decodes these values to display the Visual Editing interface.
For full details on how the encoding and decoding algorithm works, check out [`@vercel/stega`](https://www.npmjs.com/package/@vercel/stega).
### Getting Started
Setting up Payload with Vercel Visual Editing is easy. First, install the `@payloadcms/plugin-csm` plugin into your project. This plugin requires an API key to install, [contact our sales team](https://payloadcms.com/for-enterprise) if you don't already have one.
```bash
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"
const config = buildConfig({
collections: [
{
slug: "pages",
fields: [
{
name: 'slug',
type: 'text',
},
{
name: 'title,'
type: 'text',
},
],
},
],
plugins: [
contentSourceMaps({
collections: ["pages"],
}),
],
})
export default config
```
Now in your Next.js app, include the `?encodeSourceMaps=true` parameter in any of your API requests. For performance reasons, this should only be done when in draft mode or on preview deployments.
```ts
if (isDraftMode || process.env.VERCEL_ENV === "preview") {
const res = await fetch(
`${process.env.NEXT_PUBLIC_PAYLOAD_CMS_URL}/api/pages?where[slug][equals]=${slug}&encodeSourceMaps=true`
);
}
```
And that's it! You are now ready to enter Edit Mode and begin visually editing your content.
##### Edit Mode
To see Visual Editing on your site, you first need to visit any preview deployment on Vercel and login using the Vercel Toolbar. When Content Source Maps are detected on the page, a pencil icon will appear in the toolbar. Clicking this icon will enable Edit Mode, highlighting all editable fields on the page in blue.
![Versions](/images/docs/vercel-toolbar.jpg)
### Troubleshooting
##### Dates
The plugin does not encode `date` fields by default, but for some cases like text that uses negative CSS letter-spacing, it may be necessary to split the encoded data out from the rendered text. This way you can safely use the cleaned data as expected.
```ts
import { vercelStegaSplit } from "@vercel/stega";
const { cleaned, encoded } = vercelStegaSplit(text);
```
##### Blocks
All `blocks` fields by definition do not have plain text strings to encode. For this reason, blocks are given an additional `encodedSourceMap` key, which you can use to enable Visual Editing on entire sections of your site. You can then specify the editing container by adding the `data-vercel-edit-target` HTML attribute to any top-level element of your block.
```ts
<div data-vercel-edit-target>
<span style={{ display: "none" }}>{encodedSourceMap}</span>
{children}
</div>
```

View File

@@ -99,6 +99,7 @@ Note: Collection slugs must be formatted in kebab-case
example: {
slug: "createDocument",
req: {
credentials: true,
headers: true,
body: {
title: "New page",
@@ -126,6 +127,7 @@ Note: Collection slugs must be formatted in kebab-case
example: {
slug: "updateDocument",
req: {
credentials: true,
query: true,
headers: true,
body: {
@@ -155,6 +157,7 @@ Note: Collection slugs must be formatted in kebab-case
example: {
slug: "updateDocumentByID",
req: {
credentials: true,
headers: true,
body: {
title: "I have been updated by ID!",
@@ -199,6 +202,7 @@ Note: Collection slugs must be formatted in kebab-case
example: {
slug: "deleteDocuments",
req: {
credentials: true,
query: true,
headers: true,
},
@@ -225,6 +229,7 @@ Note: Collection slugs must be formatted in kebab-case
example: {
slug: "deleteByID",
req: {
credentials: true,
headers: true,
},
res: {
@@ -255,6 +260,7 @@ Auth enabled collections are also given the following endpoints:
example: {
slug: "login",
req: {
credentials: true,
headers: true,
body: {
email: "dev@payloadcms.com",
@@ -284,6 +290,7 @@ Auth enabled collections are also given the following endpoints:
slug: "logout",
req: {
headers: true,
credentials: true,
},
res: {
message: "You have been logged out successfully.",
@@ -298,6 +305,7 @@ Auth enabled collections are also given the following endpoints:
example: {
slug: "unlockCollection",
req: {
credentials: true,
headers: true,
body: {
email: "dev@payloadcms.com",
@@ -316,6 +324,7 @@ Auth enabled collections are also given the following endpoints:
example: {
slug: "refreshToken",
req: {
credentials: true,
headers: true,
},
res: {
@@ -338,7 +347,7 @@ Auth enabled collections are also given the following endpoints:
example: {
slug: "verifyUser",
req: {
prop: "token: string, user-collection: string",
credentials: true,
headers: true,
},
res: {
@@ -354,6 +363,7 @@ Auth enabled collections are also given the following endpoints:
example: {
slug: "currentUser",
req: {
credentials: true,
headers: true,
},
res: {
@@ -380,6 +390,7 @@ Auth enabled collections are also given the following endpoints:
slug: "forgotPassword",
req: {
headers: true,
credentials: true,
body: {
email: "dev@payloadcms.com",
},
@@ -397,6 +408,7 @@ Auth enabled collections are also given the following endpoints:
example: {
slug: "resetPassword",
req: {
credentials: true,
headers: true,
body: {
token: "7eac3830ffcfc7f9f66c00315dabeb11575dba91",
@@ -434,6 +446,7 @@ Globals cannot be created or deleted, so there are only two REST endpoints opene
example: {
slug: "getGlobal",
req: {
credentials: true,
headers: true,
},
res: {
@@ -454,6 +467,7 @@ Globals cannot be created or deleted, so there are only two REST endpoints opene
slug: "updateGlobal",
req: {
headers: true,
credentials: true,
body: {
announcement: "Paging Doctor Scrunt",
},
@@ -485,6 +499,7 @@ In addition to the dynamically generated endpoints above Payload also has REST e
slug: "getPreference",
req: {
headers: true,
credentials: true,
},
res: {
_id: "644bb7a8307b3d363c6edf2c",
@@ -507,6 +522,7 @@ In addition to the dynamically generated endpoints above Payload also has REST e
slug: "createPreference",
req: {
headers: true,
credentials: true,
body: {
value: "Europe/London",
},

View File

@@ -1,6 +1,6 @@
{
"name": "payload-example-auth",
"description": "Payload CMS authentication example.",
"description": "Payload authentication example.",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/

View File

@@ -1,6 +1,6 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/

View File

@@ -1,6 +1,6 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/

View File

@@ -1,6 +1,6 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/

View File

@@ -1,6 +1,6 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/

View File

@@ -1,6 +1,6 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/

View File

@@ -1,6 +1,6 @@
{
"name": "payload-example-preview",
"description": "Payload CMS preview example.",
"description": "Payload preview example.",
"version": "1.0.0",
"main": "dist/server.js",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/

View File

@@ -1,55 +1,55 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
collections: {
pages: Page;
users: User;
};
pages: Page
users: User
}
globals: {
'main-menu': MainMenu;
};
'main-menu': MainMenu
}
}
export interface Page {
id: string;
title: string;
slug?: string;
richText: {
[k: string]: unknown;
}[];
_status?: 'draft' | 'published';
createdAt: string;
updatedAt: string;
password?: string;
id: string
title: string
slug?: string
richText: Array<{
[k: string]: unknown
}>
_status?: 'draft' | 'published'
createdAt: string
updatedAt: string
password?: string
}
export interface User {
id: string;
email?: string;
resetPasswordToken?: string;
resetPasswordExpiration?: string;
loginAttempts?: number;
lockUntil?: string;
createdAt: string;
updatedAt: string;
password?: string;
id: string
email?: string
resetPasswordToken?: string
resetPasswordExpiration?: string
loginAttempts?: number
lockUntil?: string
createdAt: string
updatedAt: string
password?: string
}
export interface MainMenu {
id: string;
navItems: {
id: string
navItems: Array<{
link: {
type?: 'reference' | 'custom';
newTab?: boolean;
type?: 'reference' | 'custom'
newTab?: boolean
reference: {
value: string | Page;
relationTo: 'pages';
};
url: string;
label: string;
};
id?: string;
}[];
value: string | Page
relationTo: 'pages'
}
url: string
label: string
}
id?: string
}>
}

View File

@@ -1,6 +1,6 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/

View File

@@ -1,6 +1,6 @@
/* tslint:disable */
/**
* This file was automatically generated by Payload CMS.
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "1.9.1",
"version": "1.10.2",
"description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "MIT",
"engines": {
@@ -9,12 +9,12 @@
},
"author": {
"email": "info@payloadcms.com",
"name": "Payload CMS",
"name": "Payload",
"url": "https://payloadcms.com"
},
"maintainers": [
{
"name": "Payload CMS",
"name": "Payload",
"email": "info@payloadcms.com",
"url": "https://payloadcms.com"
}
@@ -194,7 +194,7 @@
"webpack-hot-middleware": "^2.25.3"
},
"devDependencies": {
"@playwright/test": "^1.32.3",
"@playwright/test": "1.33.0",
"@release-it/conventional-changelog": "^5.1.1",
"@swc/jest": "^0.2.24",
"@testing-library/jest-dom": "^5.16.5",
@@ -262,7 +262,7 @@
"eslint-plugin-jest-dom": "^4.0.3",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-playwright": "^0.11.2",
"eslint-plugin-playwright": "^0.12.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"form-data": "^3.0.1",

View File

@@ -1,7 +1,7 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
// Look for test files in the "tests" directory, relative to this configuration file
// Look for test files in the "test" directory, relative to this configuration file
testDir: 'test',
testMatch: '*e2e.spec.ts',
workers: 999,

View File

@@ -315,7 +315,7 @@ const Routes: React.FC = () => {
<Unauthorized />
)}
</Fragment>
) : <Redirect to={`${match.url}/login`} />}
) : <Redirect to={`${match.url}/login?redirect=${encodeURIComponent(window.location.pathname)}`} />}
</Route>
<Route path={`${match.url}*`}>
<NotFound />

View File

@@ -32,6 +32,7 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
const debouncedFields = useDebounce(fields, interval);
const fieldRef = useRef(fields);
const modifiedRef = useRef(modified);
const localeRef = useRef(locale);
// Store fields in ref so the autosave func
// can always retrieve the most to date copies
@@ -85,12 +86,12 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
let method: string;
if (collection && id) {
url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true&locale=${locale}`;
url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true&locale=${localeRef.current}`;
method = 'PATCH';
}
if (global) {
url = `${serverURL}${api}/globals/${global.slug}?draft=true&autosave=true&locale=${locale}`;
url = `${serverURL}${api}/globals/${global.slug}?draft=true&autosave=true&locale=${localeRef.current}`;
method = 'POST';
}
@@ -125,7 +126,7 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
};
autosave();
}, [i18n, debouncedFields, modified, serverURL, api, collection, global, id, getVersions, locale, modifiedRef]);
}, [i18n, debouncedFields, modified, serverURL, api, collection, global, id, getVersions, localeRef, modifiedRef]);
useEffect(() => {
if (versions?.docs?.[0]) {

View File

@@ -23,6 +23,6 @@ export const useDraggableSortable = (props: UseDraggableArguments): UseDraggable
isDragging,
listeners,
setNodeRef,
transform: transform && `translate3d(${transform.x}px, ${transform.y}px, 0)`,
transform: transform && `translate3d(${transform.x}px, ${transform.y}px, 0)`, // translate3d is faster than translate in most browsers
};
};

View File

@@ -1,14 +1,16 @@
import React from 'react';
import { components as SelectComponents, ControlProps } from 'react-select';
import { Option } from '../../../forms/field-types/Relationship/types';
import type { Option } from '../types';
export const Control: React.FC<ControlProps<Option, any>> = (props) => {
const {
children,
innerProps,
customProps: {
disableMouseDown,
disableKeyDown,
selectProps: {
customProps: {
disableMouseDown,
disableKeyDown,
} = {},
} = {},
} = props;

View File

@@ -4,12 +4,12 @@ import {
components as SelectComponents,
} from 'react-select';
import { useDraggableSortable } from '../../DraggableSortable/useDraggableSortable';
import { Option as OptionType } from '../types';
import type { Option } from '../types';
import './index.scss';
const baseClass = 'multi-value';
export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
export const MultiValue: React.FC<MultiValueProps<Option>> = (props) => {
const {
className,
isDisabled,
@@ -17,8 +17,10 @@ export const MultiValue: React.FC<MultiValueProps<OptionType>> = (props) => {
data: {
value,
},
customProps: {
disableMouseDown,
selectProps: {
customProps: {
disableMouseDown,
} = {},
} = {},
} = props;

View File

@@ -1,14 +1,16 @@
import React from 'react';
import { components as SelectComponents, MultiValueProps } from 'react-select';
import { Option } from '../../../forms/field-types/Relationship/types';
import type { Option } from '../types';
import './index.scss';
const baseClass = 'multi-value-label';
export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
const {
customProps: {
draggableProps,
selectProps: {
customProps: {
draggableProps,
} = {},
} = {},
} = props;

View File

@@ -2,6 +2,7 @@
.value-container {
flex-grow: 1;
min-width: 0;
.rs__value-container {
padding: base(.25) 0;

View File

@@ -8,7 +8,9 @@ const baseClass = 'value-container';
export const ValueContainer: React.FC<ValueContainerProps<Option, any>> = (props) => {
const {
customProps,
selectProps: {
customProps,
} = {},
} = props;
return (

View File

@@ -1,5 +1,6 @@
import React from 'react';
import React, { KeyboardEventHandler } from 'react';
import Select from 'react-select';
import CreatableSelect from 'react-select/creatable';
import { useTranslation } from 'react-i18next';
import { arrayMove } from '@dnd-kit/sortable';
import { Props as ReactSelectAdapterProps } from './types';
@@ -13,11 +14,20 @@ import { ClearIndicator } from './ClearIndicator';
import { MultiValueRemove } from './MultiValueRemove';
import { Control } from './Control';
import DraggableSortable from '../DraggableSortable';
import type { Option } from './types';
import './index.scss';
const createOption = (label: string) => ({
label,
value: label,
});
const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
const { t, i18n } = useTranslation();
const [inputValue, setInputValue] = React.useState(''); // for creatable select
const {
className,
@@ -30,9 +40,12 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
isSearchable = true,
isClearable = true,
filterOption = undefined,
numberOnly = false,
isLoading,
onMenuOpen,
components,
isCreatable,
selectProps,
} = props;
const classes = [
@@ -41,12 +54,73 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
showError && 'react-select--error',
].filter(Boolean).join(' ');
if (!isCreatable) {
return (
<Select
isLoading={isLoading}
placeholder={getTranslation(placeholder, i18n)}
captureMenuScroll
{...props}
value={value}
onChange={onChange}
isDisabled={disabled}
className={classes}
classNamePrefix="rs"
options={options}
isSearchable={isSearchable}
isClearable={isClearable}
filterOption={filterOption}
onMenuOpen={onMenuOpen}
menuPlacement="auto"
components={{
ValueContainer,
SingleValue,
MultiValue,
MultiValueLabel,
MultiValueRemove,
DropdownIndicator: Chevron,
ClearIndicator,
Control,
...components,
}}
/>
);
}
const handleKeyDown: KeyboardEventHandler = (event) => {
// eslint-disable-next-line no-restricted-globals
if (numberOnly === true) {
const acceptableKeys = ['Tab', 'Escape', 'Backspace', 'Enter', 'ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'];
const isNumber = !/[^0-9]/.test(event.key);
const isActionKey = acceptableKeys.includes(event.key);
if (!isNumber && !isActionKey) {
event.preventDefault();
return;
}
}
if (!value || !inputValue || inputValue.trim() === '') return;
if (filterOption && !filterOption(null, inputValue)) {
return;
}
switch (event.key) {
case 'Enter':
case 'Tab':
onChange([...value as Option[], createOption(inputValue)]);
setInputValue('');
event.preventDefault();
break;
default:
break;
}
};
return (
<Select
<CreatableSelect
isLoading={isLoading}
placeholder={getTranslation(placeholder, i18n)}
captureMenuScroll
{...props}
customProps={selectProps}
value={value}
onChange={onChange}
isDisabled={disabled}
@@ -58,6 +132,9 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
filterOption={filterOption}
onMenuOpen={onMenuOpen}
menuPlacement="auto"
inputValue={inputValue}
onInputChange={(newValue) => setInputValue(newValue)}
onKeyDown={handleKeyDown}
components={{
ValueContainer,
SingleValue,
@@ -79,8 +156,10 @@ const SortableSelect: React.FC<ReactSelectAdapterProps> = (props) => {
value,
} = props;
let ids: string[] = [];
if (value) ids = Array.isArray(value) ? value.map((item) => item?.value as string) : [value?.value as string]; // TODO: fix these types
if (value) ids = Array.isArray(value) ? value.map((item) => item?.id ?? `${item?.value}` as string) : [value?.id || `${value?.value}` as string];
return (
<DraggableSortable

View File

@@ -33,6 +33,8 @@ declare module 'react-select/dist/declarations/src' {
export type Option = {
[key: string]: unknown
value: unknown
//* The ID is used to identify the option in the UI. If it doesn't exist and value cannot be transformed into a string, sorting won't work */
id?: string
}
export type OptionGroup = {
@@ -48,7 +50,10 @@ export type Props = {
disabled?: boolean,
showError?: boolean,
options: Option[] | OptionGroup[]
/** Allows you to specify multiple values instead of just one */
isMulti?: boolean,
/** Allows you to create own values in the UI despite them not being pre-specified */
isCreatable?: boolean,
isLoading?: boolean
isOptionSelected?: any
isSortable?: boolean,
@@ -61,8 +66,10 @@ export type Props = {
filterOption?:
| (({ label, value, data }: { label: string, value: string, data: Option }, search: string) => boolean)
| undefined,
numberOnly?: boolean,
components?: {
[key: string]: React.FC<any>
}
selectProps?: CustomSelectProps
backspaceRemovesValue?: boolean
}

View File

@@ -49,7 +49,7 @@ export function fieldReducer(state: Fields, action: FieldAction): Fields {
// Besides those who still fail their own conditions
if (passesCondition && field.condition) {
passesCondition = field.condition(reduceFieldsToValues(state), getSiblingData(state, path), { user });
passesCondition = field.condition(reduceFieldsToValues(state, true), getSiblingData(state, path), { user });
}
return {

View File

@@ -4,8 +4,10 @@
position: relative;
margin-bottom: $baseline;
input {
@include formInput;
&:not(.has-many) {
input {
@include formInput;
}
}
&.error {

View File

@@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useField from '../../useField';
import Label from '../../Label';
@@ -8,8 +8,11 @@ import withCondition from '../../withCondition';
import { number } from '../../../../../fields/validations';
import { Props } from './types';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { Option } from '../../../elements/ReactSelect/types';
import ReactSelect from '../../../elements/ReactSelect';
import './index.scss';
import { isNumber } from '../../../../../utilities/isNumber';
const NumberField: React.FC<Props> = (props) => {
const {
@@ -20,6 +23,9 @@ const NumberField: React.FC<Props> = (props) => {
label,
max,
min,
hasMany,
minRows,
maxRows,
admin: {
readOnly,
style,
@@ -32,7 +38,7 @@ const NumberField: React.FC<Props> = (props) => {
} = {},
} = props;
const { i18n } = useTranslation();
const { t, i18n } = useTranslation();
const path = pathFromProps || name;
@@ -45,7 +51,7 @@ const NumberField: React.FC<Props> = (props) => {
showError,
setValue,
errorMessage,
} = useField({
} = useField<number | number[]>({
path,
validate: memoizedValidate,
condition,
@@ -67,8 +73,46 @@ const NumberField: React.FC<Props> = (props) => {
className,
showError && 'error',
readOnly && 'read-only',
hasMany && 'has-many',
].filter(Boolean).join(' ');
const [valueToRender, setValueToRender] = useState<{label: string, value: {value: number}, id: string}[]>([]); // Only for hasMany
const handleHasManyChange = useCallback((selectedOption) => {
if (!readOnly) {
let newValue;
if (!selectedOption) {
newValue = [];
} else if (Array.isArray(selectedOption)) {
newValue = selectedOption.map((option) => Number(option.value?.value || option.value));
} else {
newValue = [Number(selectedOption.value?.value || selectedOption.value)];
}
setValue(newValue);
}
}, [
readOnly,
setValue,
]);
// useeffect update valueToRender:
useEffect(() => {
if (hasMany && Array.isArray(value)) {
setValueToRender(value.map((val, index) => {
return {
label: `${val}`,
value: {
value: (val as any)?.value || val,
toString: () => `${val}${index}`,
}, // You're probably wondering, why the hell is this done that way? Well, React-select automatically uses "label-value" as a key, so we will get that react duplicate key warning if we just pass in the value as multiple values can be the same. So we need to append the index to the toString() of the value to avoid that warning, as it uses that as the key.
id: `${val}${index}`, // append index to avoid duplicate keys but allow duplicate numbers
};
}));
}
}, [value, hasMany]);
return (
<div
className={classes}
@@ -86,21 +130,43 @@ const NumberField: React.FC<Props> = (props) => {
label={label}
required={required}
/>
<input
id={`field-${path.replace(/\./gi, '__')}`}
value={typeof value === 'number' ? value : ''}
onChange={handleChange}
disabled={readOnly}
placeholder={getTranslation(placeholder, i18n)}
type="number"
name={path}
step={step}
onWheel={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
e.target.blur();
}}
/>
{hasMany ? (
<ReactSelect
className={`field-${path.replace(/\./gi, '__')}`}
placeholder={t('general:enterAValue')}
onChange={handleHasManyChange}
value={valueToRender as Option[]}
showError={showError}
disabled={readOnly}
options={[]}
isCreatable
isMulti
isSortable
isClearable
filterOption={(option, rawInput) => {
// eslint-disable-next-line no-restricted-globals
return isNumber(rawInput)
}}
numberOnly
/>
) : (
<input
id={`field-${path.replace(/\./gi, '__')}`}
value={typeof value === 'number' ? value : ''}
onChange={handleChange}
disabled={readOnly}
placeholder={getTranslation(placeholder, i18n)}
type="number"
name={path}
step={step}
onWheel={(e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
e.target.blur();
}}
/>
)}
<FieldDescription
value={value}
description={description}

View File

@@ -11,7 +11,8 @@
width: 100%;
div.react-select {
flex-grow: 1;
width: 100%;
min-width: 0;
}
}
@@ -22,4 +23,4 @@
background-color: var(--theme-error-500);
color: var(--theme-elevation-0);
}
}
}

View File

@@ -99,142 +99,162 @@ const Relationship: React.FC<Props> = (props) => {
const [drawerIsOpen, setDrawerIsOpen] = useState(false);
const getResults: GetResults = useCallback(async ({
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
search: searchArg,
value: valueArg,
sort,
onSuccess,
}) => {
if (!permissions) {
return;
}
const lastFullyLoadedRelationToUse = typeof lastFullyLoadedRelationArg !== 'undefined' ? lastFullyLoadedRelationArg : -1;
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
const relationsToFetch = lastFullyLoadedRelationToUse === -1 ? relations : relations.slice(lastFullyLoadedRelationToUse + 1);
let resultsFetched = 0;
const relationMap = createRelationMap({
hasMany,
relationTo,
const getResults: GetResults = useCallback(
async ({
lastFullyLoadedRelation: lastFullyLoadedRelationArg,
search: searchArg,
value: valueArg,
});
sort,
onSuccess,
}) => {
if (!permissions) {
return;
}
const lastFullyLoadedRelationToUse = typeof lastFullyLoadedRelationArg !== 'undefined'
? lastFullyLoadedRelationArg
: -1;
if (!errorLoading) {
relationsToFetch.reduce(async (priorRelation, relation) => {
const lastLoadedPageToUse = (lastLoadedPage[relation] + 1) || 1;
await priorRelation;
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
const relationsToFetch = lastFullyLoadedRelationToUse === -1
? relations
: relations.slice(lastFullyLoadedRelationToUse + 1);
if (resultsFetched < 10) {
const collection = collections.find((coll) => coll.slug === relation);
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
let resultsFetched = 0;
const relationMap = createRelationMap({
hasMany,
relationTo,
value: valueArg,
});
const query: {
[key: string]: unknown
where: Where
} = {
where: {
and: [
{
id: {
not_in: relationMap[relation],
if (!errorLoading) {
relationsToFetch.reduce(async (priorRelation, relation) => {
let lastLoadedPageToUse;
if (search !== searchArg) {
lastLoadedPageToUse = 1;
} else {
lastLoadedPageToUse = lastLoadedPage[relation] + 1;
}
await priorRelation;
if (resultsFetched < 10) {
const collection = collections.find(
(coll) => coll.slug === relation,
);
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
const query: {
[key: string]: unknown;
where: Where;
} = {
where: {
and: [
{
id: {
not_in: relationMap[relation],
},
},
},
],
},
limit: maxResultsPerRequest,
page: lastLoadedPageToUse,
sort: fieldToSearch,
locale,
depth: 0,
};
if (searchArg) {
query.where.and.push({
[fieldToSearch]: {
like: searchArg,
],
},
});
}
limit: maxResultsPerRequest,
page: lastLoadedPageToUse,
sort: fieldToSearch,
locale,
depth: 0,
};
if (filterOptionsResult?.[relation]) {
query.where.and.push(filterOptionsResult[relation]);
}
const response = await fetch(`${serverURL}${api}/${relation}?${qs.stringify(query)}`, {
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
},
});
if (response.ok) {
const data: PaginatedDocs<unknown> = await response.json();
setLastLoadedPage((prevState) => ({
...prevState,
[relation]: lastLoadedPageToUse,
}));
if (!data.nextPage) {
setLastFullyLoadedRelation(relations.indexOf(relation));
if (searchArg) {
query.where.and.push({
[fieldToSearch]: {
like: searchArg,
},
});
}
if (data.docs.length > 0) {
resultsFetched += data.docs.length;
if (filterOptionsResult?.[relation]) {
query.where.and.push(filterOptionsResult[relation]);
}
const response = await fetch(
`${serverURL}${api}/${relation}?${qs.stringify(query)}`,
{
credentials: 'include',
headers: {
'Accept-Language': i18n.language,
},
},
);
if (response.ok) {
const data: PaginatedDocs<unknown> = await response.json();
setLastLoadedPage((prevState) => {
return {
...prevState,
[relation]: lastLoadedPageToUse,
};
});
if (!data.nextPage) {
setLastFullyLoadedRelation(relations.indexOf(relation));
}
if (data.docs.length > 0) {
resultsFetched += data.docs.length;
dispatchOptions({
type: 'ADD',
docs: data.docs,
collection,
sort,
i18n,
config,
});
}
} else if (response.status === 403) {
setLastFullyLoadedRelation(relations.indexOf(relation));
dispatchOptions({
type: 'ADD',
docs: data.docs,
docs: [],
collection,
sort,
ids: relationMap[relation],
i18n,
config,
});
} else {
setErrorLoading(t('error:unspecific'));
}
} else if (response.status === 403) {
setLastFullyLoadedRelation(relations.indexOf(relation));
dispatchOptions({
type: 'ADD',
docs: [],
collection,
sort,
ids: relationMap[relation],
i18n,
config,
});
} else {
setErrorLoading(t('error:unspecific'));
}
}
}, Promise.resolve());
}, Promise.resolve());
if (typeof onSuccess === 'function') onSuccess();
}
}, [
lastLoadedPage,
permissions,
relationTo,
hasMany,
errorLoading,
collections,
filterOptionsResult,
serverURL,
api,
t,
i18n,
locale,
config,
]);
if (typeof onSuccess === 'function') onSuccess();
}
},
[
permissions,
relationTo,
hasMany,
errorLoading,
search,
lastLoadedPage,
collections,
locale,
filterOptionsResult,
serverURL,
api,
i18n,
config,
t,
],
);
const updateSearch = useDebouncedCallback((searchArg: string, valueArg: Value | Value[]) => {
getResults({ search: searchArg, value: valueArg, sort: true });
setSearch(searchArg);
}, [getResults]);
}, 300);
const handleInputChange = useCallback((searchArg: string, valueArg: Value | Value[]) => {
if (search !== searchArg) {
setLastLoadedPage({});
updateSearch(searchArg, valueArg);
}
}, [search, updateSearch]);
@@ -375,6 +395,7 @@ const Relationship: React.FC<Props> = (props) => {
{!errorLoading && (
<div className={`${baseClass}__wrap`}>
<ReactSelect
backspaceRemovesValue={!drawerIsOpen}
disabled={readOnly || formProcessing}
onInputChange={(newSearch) => handleInputChange(newSearch, value)}
onChange={!readOnly ? (selected) => {

View File

@@ -17,10 +17,12 @@ export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
relationTo,
label,
},
customProps: {
setDrawerIsOpen,
draggableProps,
onSave,
selectProps: {
customProps: {
setDrawerIsOpen,
draggableProps,
onSave,
} = {},
} = {},
} = props;

View File

@@ -2,6 +2,11 @@
.relationship--single-value {
&.rs__single-value {
overflow: visible;
min-width: 0;
}
&__label-text {
max-width: unset;
display: flex;

View File

@@ -18,9 +18,11 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
label,
},
children,
customProps: {
setDrawerIsOpen,
onSave,
selectProps: {
customProps: {
setDrawerIsOpen,
onSave,
} = {},
} = {},
} = props;
@@ -35,7 +37,9 @@ export const SingleValue: React.FC<SingleValueProps<Option>> = (props) => {
});
useEffect(() => {
if (typeof setDrawerIsOpen === 'function') setDrawerIsOpen(isDrawerOpen);
if (typeof setDrawerIsOpen === 'function') {
setDrawerIsOpen(isDrawerOpen);
}
}, [isDrawerOpen, setDrawerIsOpen]);
return (

View File

@@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
import { useAuth } from '../../utilities/Auth';
import { useAllFormFields } from '../Form/context';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import reduceFieldsToValues from '../Form/reduceFieldsToValues';
import getSiblingData from '../Form/getSiblingData';
import { Condition } from '../../../../fields/config/types';
type Props = {
path?: string
name: string
condition: Condition
setShowField: (isVisible: boolean) => void
}
export const WatchCondition: React.FC<Props> = ({
path: pathFromProps,
name,
condition,
setShowField,
}) => {
const path = typeof pathFromProps === 'string' ? pathFromProps : name;
const { user } = useAuth();
const [fields, dispatchFields] = useAllFormFields();
const { id } = useDocumentInfo();
const data = reduceFieldsToValues(fields, true);
const siblingData = getSiblingData(fields, path);
// Manually provide ID to `data`
data.id = id;
const hasCondition = Boolean(condition);
const isPassingCondition = hasCondition ? condition(data, siblingData, { user }) : true;
const field = fields[path];
const wasPassingCondition = field?.passesCondition;
useEffect(() => {
if (hasCondition) {
if (isPassingCondition && !wasPassingCondition) {
dispatchFields({ type: 'MODIFY_CONDITION', path, result: true, user });
}
if (!isPassingCondition && (wasPassingCondition || typeof wasPassingCondition === 'undefined')) {
dispatchFields({ type: 'MODIFY_CONDITION', path, result: false, user });
}
}
}, [isPassingCondition, wasPassingCondition, dispatchFields, path, hasCondition, user, setShowField]);
useEffect(() => {
setShowField(isPassingCondition);
}, [setShowField, isPassingCondition]);
return null;
};

View File

@@ -1,12 +1,8 @@
'use client';
import React, { useEffect } from 'react';
import React from 'react';
import { FieldBase } from '../../../../fields/config/types';
import { useAllFormFields } from '../Form/context';
import getSiblingData from '../Form/getSiblingData';
import reduceFieldsToValues from '../Form/reduceFieldsToValues';
import { useDocumentInfo } from '../../utilities/DocumentInfo';
import { useAuth } from '../../utilities/Auth';
import { WatchCondition } from './WatchCondition';
const withCondition = <P extends Record<string, unknown>>(Field: React.ComponentType<P>): React.FC<P> => {
const CheckForCondition: React.FC<P> = (props) => {
@@ -26,7 +22,7 @@ const withCondition = <P extends Record<string, unknown>>(Field: React.Component
const WithCondition: React.FC<P> = (props) => {
const {
name,
path: pathFromProps,
path,
admin: {
condition,
} = {},
@@ -34,41 +30,30 @@ const withCondition = <P extends Record<string, unknown>>(Field: React.Component
path?: string
};
const path = typeof pathFromProps === 'string' ? pathFromProps : name;
const [showField, setShowField] = React.useState(false);
const { user } = useAuth();
const [fields, dispatchFields] = useAllFormFields();
const { id } = useDocumentInfo();
const data = reduceFieldsToValues(fields, true);
const siblingData = getSiblingData(fields, path);
// Manually provide ID to `data`
data.id = id;
const hasCondition = Boolean(condition);
const currentlyPassesCondition = hasCondition ? condition(data, siblingData, { user }) : true;
const field = fields[path];
const existingConditionPasses = field?.passesCondition;
useEffect(() => {
if (hasCondition) {
if (!existingConditionPasses && currentlyPassesCondition) {
dispatchFields({ type: 'MODIFY_CONDITION', path, result: true, user });
}
if (!currentlyPassesCondition && (existingConditionPasses || typeof existingConditionPasses === 'undefined')) {
dispatchFields({ type: 'MODIFY_CONDITION', path, result: false, user });
}
}
}, [currentlyPassesCondition, existingConditionPasses, dispatchFields, path, hasCondition, user]);
if (currentlyPassesCondition) {
return <Field {...props} />;
if (showField) {
return (
<React.Fragment>
<WatchCondition
path={path}
name={name}
condition={condition}
setShowField={setShowField}
/>
<Field {...props} />
</React.Fragment>
);
}
return null;
return (
<WatchCondition
path={path}
name={name}
condition={condition}
setShowField={setShowField}
/>
);
};
return CheckForCondition;

View File

@@ -31,8 +31,8 @@ const Default: React.FC<Props> = ({ children, className }) => {
<div className={classes}>
<Meta
title={t('dashboard')}
description={`${t('dashboard')} Payload CMS`}
keywords={`${t('dashboard')}, Payload CMS`}
description={`${t('dashboard')} Payload`}
keywords={`${t('dashboard')}, Payload`}
/>
<RenderCustomComponent
DefaultComponent={DefaultNav}

View File

@@ -61,7 +61,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
setUser(json.user);
} else {
setUser(null);
push(`${admin}${logoutInactivityRoute}`);
push(`${admin}${logoutInactivityRoute}?redirect=${encodeURIComponent(window.location.pathname)}`);
}
}, 1000);
}
@@ -159,7 +159,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
if (remainingTime > 0) {
forceLogOut = setTimeout(() => {
setUser(null);
push(`${admin}${logoutInactivityRoute}`);
push(`${admin}${logoutInactivityRoute}?redirect=${encodeURIComponent(window.location.pathname)}`);
closeAllModals();
}, Math.min(remainingTime * 1000, maxTimeoutTime));
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Link, useHistory } from 'react-router-dom';
import { Link, useHistory, useLocation } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
@@ -41,10 +41,16 @@ const Login: React.FC = () => {
const collection = collections.find(({ slug }) => slug === userSlug);
// Fetch 'redirect' from the query string which denotes the URL the user originally tried to visit. This is set in the Routes.tsx file when a user tries to access a protected route and is redirected to the login screen.
const query = new URLSearchParams(useLocation().search);
const redirect = query.get('redirect');
const onSuccess = (data) => {
if (data.token) {
setToken(data.token);
history.push(admin);
history.push(redirect || admin);
}
};

View File

@@ -1,5 +1,6 @@
import React, { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { useConfig } from '../../utilities/Config';
import { useAuth } from '../../utilities/Auth';
import Minimal from '../../templates/Minimal';
@@ -17,6 +18,10 @@ const Logout: React.FC<{inactivity?: boolean}> = (props) => {
const { routes: { admin } } = useConfig();
const { t } = useTranslation('authentication');
// Fetch 'redirect' from the query string which denotes the URL the user originally tried to visit. This is set in the Routes.tsx file when a user tries to access a protected route and is redirected to the login screen.
const query = new URLSearchParams(useLocation().search);
const redirect = query.get('redirect');
useEffect(() => {
logOut();
}, [logOut]);
@@ -39,7 +44,7 @@ const Logout: React.FC<{inactivity?: boolean}> = (props) => {
<Button
el="anchor"
buttonStyle="secondary"
url={`${admin}/login`}
url={`${admin}/login${redirect && redirect.length > 0 ? `?redirect=${encodeURIComponent(redirect)}` : ''}`}
>
{t('logBackIn')}
</Button>

View File

@@ -76,7 +76,7 @@ const CompareVersion: React.FC<Props> = (props) => {
setOptions((existingOptions) => [
...existingOptions,
...data.docs.map((doc) => ({
label: formatDate(doc.createdAt, dateFormat, i18n?.language),
label: formatDate(doc.updatedAt, dateFormat, i18n?.language),
value: doc.id,
})),
]);

View File

@@ -2,6 +2,7 @@ export const diffStyles = {
variables: {
light: {
diffViewerBackground: 'transparent',
diffViewerColor: 'var(--theme-text)',
addedBackground: 'var(--theme-success-100)',
addedColor: 'var(--theme-success-900)',
removedBackground: 'var(--theme-error-100)',
@@ -10,5 +11,16 @@ export const diffStyles = {
wordRemovedBackground: 'var(--theme-error-200)',
emptyLineBackground: 'var(--theme-elevation-50)',
},
dark: {
diffViewerBackground: 'transparent',
diffViewerColor: 'var(--theme-text)',
addedBackground: 'var(--theme-success-900)',
addedColor: 'var(--theme-success-100)',
removedBackground: 'var(--theme-error-900)',
removedColor: 'var(--theme-error-100)',
wordAddedBackground: 'var(--theme-success-800)',
wordRemovedBackground: 'var(--theme-error-800)',
emptyLineBackground: 'var(--theme-elevation-50)',
},
},
};

View File

@@ -34,7 +34,7 @@
@include mid-break {
--gutter-h: #{base(2)};
--nav-width: 0;
--nav-width: 0px;
}
@include small-break {

View File

@@ -68,6 +68,10 @@ async function forgotPassword(incomingArgs: Arguments): Promise<string | null> {
resetPasswordExpiration?: Date,
}
if (!data.email) {
throw new APIError('Missing email.');
}
let user = await payload.db.findOne<UserDoc>({
collection: collectionConfig.slug,
where: { email: { equals: (data.email as string).toLowerCase() } },

View File

@@ -34,7 +34,7 @@ async function localForgotPassword<T extends keyof GeneratedTypes['collections']
throw new APIError(`The collection with slug ${String(collectionSlug)} can't be found.`);
}
req.payloadAPI = 'local';
req.payloadAPI = req.payloadAPI || 'local';
req.payload = payload;
req.i18n = i18n(payload.config.i18n);

View File

@@ -44,7 +44,7 @@ async function localLogin<TSlug extends keyof GeneratedTypes['collections']>(
throw new APIError(`The collection with slug ${String(collectionSlug)} can't be found.`);
}
req.payloadAPI = 'local';
req.payloadAPI = req.payloadAPI || 'local';
req.payload = payload;
req.i18n = i18n(payload.config.i18n);
req.locale = undefined;

View File

@@ -34,7 +34,7 @@ async function localResetPassword<T extends keyof GeneratedTypes['collections']>
}
req.payload = payload;
req.payloadAPI = 'local';
req.payloadAPI = req.payloadAPI || 'local';
req.i18n = i18n(payload.config.i18n);
if (!req.t) req.t = req.i18n.t;

View File

@@ -33,7 +33,7 @@ async function localUnlock<T extends keyof GeneratedTypes['collections']>(
}
req.payload = payload;
req.payloadAPI = 'local';
req.payloadAPI = req.payloadAPI || 'local';
req.i18n = i18n(payload.config.i18n);
if (!req.t) req.t = req.i18n.t;

View File

@@ -7,7 +7,6 @@ import { fieldAffectsData } from '../../fields/config/types';
import { PayloadRequest } from '../../express/types';
import { authenticateLocalStrategy } from '../strategies/local/authenticate';
import { generatePasswordSaltHash } from '../strategies/local/generatePasswordSaltHash';
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
export type Result = {
token: string

View File

@@ -45,6 +45,10 @@ async function unlock(args: Args): Promise<boolean> {
// Unlock
// /////////////////////////////////////
if (!data.email) {
throw new APIError('Missing email.');
}
const user = await req.payload.db.findOne({
collection: collectionConfig.slug,
where: { email: { equals: data.email.toLowerCase() } },

View File

@@ -2,15 +2,12 @@
/* eslint-disable global-require */
import webpack from 'webpack';
import getWebpackProdConfig from '../webpack/getProdConfig';
import findConfig from '../config/find';
import loadConfig from '../config/load';
const rawConfigPath = findConfig();
export const build = async (): Promise<void> => {
try {
const config = await loadConfig();
const config = await loadConfig(); // Will throw its own error if it fails
try {
const webpackProdConfig = getWebpackProdConfig(config);
webpack(webpackProdConfig, (err, stats) => { // Stats Object
@@ -29,7 +26,7 @@ export const build = async (): Promise<void> => {
});
} catch (err) {
console.error(err);
throw new Error(`Error: can't find the configuration file located at ${rawConfigPath}.`);
throw new Error('Error: there was an error building the webpack config.');
}
};

View File

@@ -1,33 +1,9 @@
/* eslint-disable no-nested-ternary */
import fs from 'fs';
import type { JSONSchema4 } from 'json-schema';
import { compile } from 'json-schema-to-typescript';
import Logger from '../utilities/logger';
import { SanitizedConfig } from '../config/types';
import loadConfig from '../config/load';
import { entityToJSONSchema, generateEntitySchemas } from '../utilities/entityToJSONSchema';
type DefinitionsType = { [k: string]: JSONSchema4 };
function configToJsonSchema(config: SanitizedConfig): JSONSchema4 {
const fieldDefinitionsMap: Map<string, JSONSchema4> = new Map(); // mutable
const entityDefinitions: DefinitionsType = [...config.globals, ...config.collections].reduce((acc, entity) => {
acc[entity.slug] = entityToJSONSchema(config, entity, fieldDefinitionsMap);
return acc;
}, {});
return {
title: 'Config',
type: 'object',
additionalProperties: false,
properties: {
collections: generateEntitySchemas(config.collections),
globals: generateEntitySchemas(config.globals),
},
required: ['collections', 'globals'],
definitions: { ...entityDefinitions, ...Object.fromEntries(fieldDefinitionsMap) },
};
}
import { configToJSONSchema } from '../utilities/configToJSONSchema';
export async function generateTypes(): Promise<void> {
const logger = Logger();
@@ -36,10 +12,10 @@ export async function generateTypes(): Promise<void> {
logger.info('Compiling TS types for Collections and Globals...');
const jsonSchema = configToJsonSchema(config);
const jsonSchema = configToJSONSchema(config);
compile(jsonSchema, 'Config', {
bannerComment: '/* tslint:disable */\n/**\n* This file was automatically generated by Payload CMS.\n* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,\n* and re-run `payload generate:types` to regenerate this file.\n*/',
bannerComment: '/* tslint:disable */\n/* eslint-disable */\n/**\n* This file was automatically generated by Payload.\n* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,\n* and re-run `payload generate:types` to regenerate this file.\n*/',
style: {
singleQuote: true,
},

View File

@@ -348,7 +348,7 @@ export type CollectionConfig = {
* @default true
*/
timestamps?: boolean
/** Extension point to add your custom data. */
/** Extension point to add your custom data. */
custom?: Record<string, any>;
};

View File

@@ -3,7 +3,6 @@ import { PayloadRequest } from '../../express/types';
import executeAccess from '../../auth/executeAccess';
import { Collection, TypeWithID } from '../config/types';
import { PaginatedDocs } from '../../mongoose/types';
import { buildSortParam } from '../../mongoose/queries/buildSortParam';
import { AccessResult } from '../../config/types';
import { afterRead } from '../../fields/hooks/afterRead';
import { validateQueryPaths } from '../../database/queryValidation/validateQueryPaths';
@@ -56,6 +55,7 @@ async function find<T extends TypeWithID & Record<string, unknown>>(
collection: {
config: collectionConfig,
},
sort,
req,
req: {
locale,
@@ -97,14 +97,6 @@ async function find<T extends TypeWithID & Record<string, unknown>>(
// Find
// /////////////////////////////////////
const sort = buildSortParam({
sort: args.sort ?? collectionConfig.defaultSort,
config: payload.config,
fields: collectionConfig.fields,
timestamps: collectionConfig.timestamps,
locale,
});
const usePagination = pagination && limit !== 0;
const sanitizedLimit = limit ?? (usePagination ? 10 : 0);
const sanitizedPage = page || 1;

View File

@@ -3,7 +3,6 @@ import { PayloadRequest } from '../../express/types';
import executeAccess from '../../auth/executeAccess';
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
import { Collection } from '../config/types';
import { buildSortParam } from '../../mongoose/queries/buildSortParam';
import { PaginatedDocs } from '../../mongoose/types';
import { TypeWithVersion } from '../../versions/types';
import { afterRead } from '../../fields/hooks/afterRead';
@@ -34,6 +33,7 @@ async function findVersions<T extends TypeWithVersion<T>>(
collection: {
config: collectionConfig,
},
sort,
req,
req: {
locale,
@@ -69,14 +69,6 @@ async function findVersions<T extends TypeWithVersion<T>>(
// Find
// /////////////////////////////////////
const sort = buildSortParam({
sort: args.sort || '-updatedAt',
fields: versionFields,
timestamps: true,
config: payload.config,
locale,
});
const paginatedDocs = await payload.db.findVersions<T>({
where: fullWhere,
page: page || 1,

View File

@@ -56,7 +56,7 @@ export default async function createLocal<TSlug extends keyof GeneratedTypes['co
throw new APIError(`The collection with slug ${String(collectionSlug)} can't be found.`);
}
req.payloadAPI = 'local';
req.payloadAPI = req.payloadAPI || 'local';
req.locale = locale ?? req?.locale ?? defaultLocale;
req.fallbackLocale = fallbackLocale ?? req?.fallbackLocale ?? defaultLocale;
req.payload = payload;

View File

@@ -57,7 +57,7 @@ export default async function findLocal<T extends keyof GeneratedTypes['collecti
throw new APIError(`The collection with slug ${String(collectionSlug)} can't be found.`);
}
req.payloadAPI = 'local';
req.payloadAPI = req.payloadAPI || 'local';
req.locale = locale ?? req?.locale ?? defaultLocale;
req.fallbackLocale = fallbackLocale ?? req?.fallbackLocale ?? defaultLocale;
req.i18n = i18n(payload.config.i18n);

View File

@@ -48,7 +48,7 @@ export default async function findByIDLocal<T extends keyof GeneratedTypes['coll
throw new APIError(`The collection with slug ${String(collectionSlug)} can't be found.`);
}
req.payloadAPI = 'local';
req.payloadAPI = req.payloadAPI || 'local';
req.locale = locale ?? req?.locale ?? defaultLocale;
req.fallbackLocale = fallbackLocale ?? req?.fallbackLocale ?? defaultLocale;
req.i18n = i18n(payload.config.i18n);

View File

@@ -44,7 +44,7 @@ export default async function findVersionByIDLocal<T extends keyof GeneratedType
throw new APIError(`The collection with slug ${String(collectionSlug)} can't be found.`);
}
req.payloadAPI = 'local';
req.payloadAPI = req.payloadAPI || 'local';
req.locale = locale ?? req?.locale ?? defaultLocale;
req.fallbackLocale = fallbackLocale ?? req?.fallbackLocale ?? defaultLocale;
req.i18n = i18n(payload.config.i18n);

View File

@@ -78,7 +78,7 @@ async function update<TSlug extends keyof GeneratedTypes['collections']>(
throw new APIError('Missing \'where\' query of documents to update.', httpStatus.BAD_REQUEST);
}
let { data } = args;
const { data: bulkUpdateData } = args;
const shouldSaveDraft = Boolean(draftArg && collectionConfig.versions.drafts);
// /////////////////////////////////////
@@ -146,17 +146,19 @@ async function update<TSlug extends keyof GeneratedTypes['collections']>(
config,
collection,
req,
data,
data: bulkUpdateData,
throwOnMissingFile: false,
overwriteExistingFiles,
});
data = newFileData;
const errors = [];
const promises = docs.map(async (doc) => {
const { id } = doc;
let data = {
...newFileData,
...bulkUpdateData,
};
try {
const originalDoc = await afterRead({

View File

@@ -195,7 +195,7 @@ export type Endpoint = {
* @default false
*/
root?: boolean;
/** Extension point to add your custom data. */
/** Extension point to add your custom data. */
custom?: Record<string, any>;
};
@@ -529,7 +529,7 @@ export type Config = {
telemetry?: boolean;
/** A function that is called immediately following startup that receives the Payload instance as its only argument. */
onInit?: (payload: Payload) => Promise<void> | void;
/** Extension point to add your custom data. */
/** Extension point to add your custom data. */
custom?: Record<string, any>;
/** Pass in a database adapter for use on this project. */
db?: DatabaseAdapter

View File

@@ -1,6 +1,6 @@
import type { SchemaOptions } from 'mongoose';
import type { Configuration } from 'webpack';
import type { Config, SanitizedConfig } from '../config/types';
import type { SanitizedConfig } from '../config/types';
import type {
ArrayField,
BlockField,
@@ -148,7 +148,7 @@ export type QueryDraftsArgs = {
page?: number
limit?: number
pagination?: boolean
sort?: SortArgs
sort?: string
locale?: string
}
@@ -172,7 +172,7 @@ export type FindArgs = {
/** Setting limit to 1 is equal to the previous Model.findOne(). Setting limit to 0 disables the limit */
limit?: number
pagination?: boolean
sort?: SortArgs
sort?: string
locale?: string
}
@@ -186,7 +186,7 @@ export type FindVersionsArgs = {
versions?: boolean
limit?: number
pagination?: boolean
sort?: SortArgs
sort?: string
locale?: string
}
@@ -201,7 +201,7 @@ export type FindGlobalVersionsArgs = {
versions?: boolean
limit?: number
pagination?: boolean
sort?: SortArgs
sort?: string
locale?: string
}
@@ -298,16 +298,6 @@ export type BuildSchemaOptions = {
indexSortableFields?: boolean
}
export type BuildSortParam = (args: {
sort: string
config: Config
fields: Field[]
timestamps: boolean
locale: string
}) => {
sort?: SortArgs
}
export type PaginatedDocs<T = any> = {
docs: T[]
totalDocs: number
@@ -379,10 +369,3 @@ export type FieldGenerator<TSchema, TField> = {
config: SanitizedConfig,
options: BuildSchemaOptions,
}
export type SortArgs = {
property: string
direction: SortDirection
}[]
export type SortDirection = 'asc' | 'desc';

View File

@@ -1,6 +1,6 @@
import { EmailOptions } from '../config/types';
export const defaults: EmailOptions = {
fromName: 'Payload CMS',
fromName: 'Payload',
fromAddress: 'info@payloadcms.com',
};

View File

@@ -35,6 +35,17 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[]
throw new InvalidFieldRelationship(field, relationship);
}
});
if (field.type === 'relationship') {
if (field.min && !field.minRows) {
console.warn(`(payload): The "min" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "minRows" instead.`);
}
if (field.max && !field.maxRows) {
console.warn(`(payload): The "max" property is deprecated for the Relationship field "${field.name}" and will be removed in a future version. Please use "maxRows" instead.`);
}
field.minRows = field.minRows || field.min;
field.maxRows = field.maxRows || field.max;
}
}
if (field.type === 'blocks' && field.blocks) {

View File

@@ -94,6 +94,11 @@ export const number = baseField.keys({
autoComplete: joi.string(),
step: joi.number(),
}),
hasMany: joi.boolean().default(false),
minRows: joi.number()
.when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
maxRows: joi.number()
.when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
});
export const textarea = baseField.keys({
@@ -336,8 +341,14 @@ export const relationship = baseField.keys({
allowCreate: joi.boolean().default(true),
}),
min: joi.number()
.when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
.when('hasMany', { is: joi.not(true), then: joi.forbidden() })
.warning('deprecated', { message: 'Use minRows instead.' }),
max: joi.number()
.when('hasMany', { is: joi.not(true), then: joi.forbidden() })
.warning('deprecated', { message: 'Use maxRows instead.' }),
minRows: joi.number()
.when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
maxRows: joi.number()
.when('hasMany', { is: joi.not(true), then: joi.forbidden() }),
});
@@ -455,7 +466,10 @@ export const date = baseField.keys({
export const ui = joi.object().keys({
name: joi.string().required(),
label: joi.string(),
label: joi.alternatives().try(
joi.string(),
joi.object().pattern(joi.string(), [joi.string()]),
),
type: joi.string().valid('ui').required(),
admin: joi.object().keys({
position: joi.string().valid('sidebar'),
@@ -466,6 +480,7 @@ export const ui = joi.object().keys({
Field: componentSchema,
}).default({}),
}).default(),
custom: joi.object().pattern(joi.string(), joi.any()),
});
const fieldSchema = joi.alternatives()

View File

@@ -122,20 +122,40 @@ export interface FieldBase {
read?: FieldAccess;
update?: FieldAccess;
};
/** Extension point to add your custom data. */
/** Extension point to add your custom data. */
custom?: Record<string, any>;
}
export type NumberField = FieldBase & {
type: 'number';
admin?: Admin & {
/** Set this property to a string that will be used for browser autocomplete. */
autoComplete?: string
/** Set this property to define a placeholder string for the field. */
placeholder?: Record<string, string> | string
/** Set a value for the number field to increment / decrement using browser controls. */
step?: number
}
/** Minimum value accepted. Used in the default `validation` function. */
min?: number
/** Maximum value accepted. Used in the default `validation` function. */
max?: number
}
} & ({
/** Makes this field an ordered array of numbers instead of just a single number. */
hasMany: true
/** Minimum number of numbers in the numbers array, if `hasMany` is set to true. */
minRows?: number
/** Maximum number of numbers in the numbers array, if `hasMany` is set to true. */
maxRows?: number
} | {
/** Makes this field an ordered array of numbers instead of just a single number. */
hasMany?: false | undefined
/** Minimum number of numbers in the numbers array, if `hasMany` is set to true. */
minRows?: undefined
/** Maximum number of numbers in the numbers array, if `hasMany` is set to true. */
maxRows?: undefined
})
export type TextField = FieldBase & {
type: 'text';
@@ -261,7 +281,7 @@ export type UIField = {
}
}
type: 'ui';
/** Extension point to add your custom data. */
/** Extension point to add your custom data. */
custom?: Record<string, any>;
}
@@ -312,15 +332,31 @@ export type RelationshipField = FieldBase & {
admin?: Admin & {
isSortable?: boolean;
allowCreate?: boolean;
}
},
} & ({
hasMany: true
/**
* @deprecated Use 'minRows' instead
*/
min?: number
/**
* @deprecated Use 'maxRows' instead
*/
max?: number
minRows?: number
maxRows?: number
} | {
hasMany?: false | undefined
/**
* @deprecated Use 'minRows' instead
*/
min?: undefined
/**
* @deprecated Use 'maxRows' instead
*/
max?: undefined
minRows?: undefined
maxRows?: undefined
})
export type ValueWithRelation = {

View File

@@ -223,7 +223,7 @@ describe('Field Validations', () => {
const minOptions = {
...relationshipOptions,
hasMany: true,
min: 2,
minRows: 2,
};
const val = ['a'];
@@ -237,7 +237,7 @@ describe('Field Validations', () => {
it('should enforce hasMany max', async () => {
const maxOptions = {
...relationshipOptions,
max: 2,
maxRows: 2,
hasMany: true,
};
let val = ['a', 'b', 'c'];
@@ -424,5 +424,30 @@ describe('Field Validations', () => {
const result = number(val, { ...numberOptions, max: 1 });
expect(result).toBe('validation:greaterThanMax');
});
it('should validate an array of numbers', async () => {
const val = [1.25, 2.5];
const result = number(val, { ...numberOptions, hasMany: true });
expect(result).toBe(true);
});
it('should validate an array of numbers using min', async () => {
const val = [1.25, 2.5];
const result = number(val, { ...numberOptions, hasMany: true, min: 3 });
expect(result).toBe('validation:lessThanMin');
});
it('should validate an array of numbers using max', async () => {
const val = [1.25, 2.5];
const result = number(val, { ...numberOptions, hasMany: true, max: 1 });
expect(result).toBe('validation:greaterThanMax');
});
it('should validate an array of numbers using minRows', async () => {
const val = [1.25, 2.5];
const result = number(val, { ...numberOptions, hasMany: true, minRows: 4 });
expect(result).toBe('validation:lessThanMin');
});
it('should validate an array of numbers using maxRows', async () => {
const val = [1.25, 2.5, 3.5];
const result = number(val, { ...numberOptions, hasMany: true, maxRows: 2 });
expect(result).toBe('validation:greaterThanMax');
});
});
});

View File

@@ -24,25 +24,45 @@ import canUseDOM from '../utilities/canUseDOM';
import { isValidID } from '../utilities/isValidID';
import { getIDType } from '../utilities/getIDType';
export const number: Validate<unknown, unknown, NumberField> = (value: string, { t, required, min, max }) => {
const parsedValue = parseFloat(value);
export const number: Validate<unknown, unknown, NumberField> = (value: number | number[], { t, required, min, max, minRows, maxRows, hasMany }) => {
const toValidate: number[] = Array.isArray(value) ? value : [value];
if ((value && typeof parsedValue !== 'number') || (required && Number.isNaN(parsedValue)) || (value && Number.isNaN(parsedValue))) {
return t('validation:enterNumber');
// eslint-disable-next-line no-restricted-syntax
for (const valueToValidate of toValidate) {
const floatValue = parseFloat(valueToValidate as unknown as string);
if ((value && typeof floatValue !== 'number') || (required && Number.isNaN(floatValue)) || (value && Number.isNaN(floatValue))) {
return t('validation:enterNumber');
}
if (typeof max === 'number' && floatValue > max) {
return t('validation:greaterThanMax', { value, max, label: t('value') });
}
if (typeof min === 'number' && floatValue < min) {
return t('validation:lessThanMin', { value, min, label: t('value') });
}
if (required && typeof floatValue !== 'number') {
return t('validation:required');
}
}
if (typeof max === 'number' && parsedValue > max) {
return t('validation:greaterThanMax', { value, max });
}
if (typeof min === 'number' && parsedValue < min) {
return t('validation:lessThanMin', { value, min });
}
if (required && typeof parsedValue !== 'number') {
if (required && toValidate.length === 0) {
return t('validation:required');
}
if (hasMany === true) {
if (minRows && toValidate.length < minRows) {
return t('validation:lessThanMin', { value: toValidate.length, min: minRows, label: t('rows') });
}
if (maxRows && toValidate.length > maxRows) {
return t('validation:greaterThanMax', { value: toValidate.length, max: maxRows, label: t('rows') });
}
}
return true;
};
@@ -214,6 +234,8 @@ const validateFilterOptions: Validate = async (value, { t, filterOptions, id, us
const result = await payload.find({
collection,
depth: 0,
limit: 0,
pagination: false,
where: {
and: [
{ id: { in: valueIDs } },
@@ -277,8 +299,8 @@ export const upload: Validate<unknown, unknown, UploadField> = (value: string, o
export const relationship: Validate<unknown, unknown, RelationshipField> = async (value: RelationshipValue, options) => {
const {
required,
min,
max,
minRows,
maxRows,
relationTo,
payload,
t,
@@ -289,12 +311,12 @@ export const relationship: Validate<unknown, unknown, RelationshipField> = async
}
if (Array.isArray(value)) {
if (min && value.length < min) {
return t('validation:lessThanMin', { count: min, label: t('rows') });
if (minRows && value.length < minRows) {
return t('validation:lessThanMin', { value: value.length, min: minRows, label: t('rows') });
}
if (max && value.length > max) {
return t('validation:greaterThanMax', { count: max, label: t('rows') });
if (maxRows && value.length > maxRows) {
return t('validation:greaterThanMax', { value: value.length, max: maxRows, label: t('rows') });
}
}

View File

@@ -138,7 +138,7 @@ export type GlobalConfig = {
}
fields: Field[];
admin?: GlobalAdminOptions
/** Extension point to add your custom data. */
/** Extension point to add your custom data. */
custom?: Record<string, any>;
}

View File

@@ -3,7 +3,6 @@ import { PayloadRequest } from '../../express/types';
import executeAccess from '../../auth/executeAccess';
import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
import { PaginatedDocs } from '../../mongoose/types';
import { buildSortParam } from '../../mongoose/queries/buildSortParam';
import { SanitizedGlobalConfig } from '../config/types';
import { afterRead } from '../../fields/hooks/afterRead';
import { buildVersionGlobalFields } from '../../versions/buildGlobalFields';
@@ -32,6 +31,7 @@ async function findVersions<T extends TypeWithVersion<T>>(
limit,
depth,
globalConfig,
sort,
req,
req: {
locale,
@@ -63,14 +63,6 @@ async function findVersions<T extends TypeWithVersion<T>>(
// Find
// /////////////////////////////////////
const sort = buildSortParam({
sort: args.sort || '-updatedAt',
fields: versionFields,
timestamps: true,
config: payload.config,
locale,
});
const paginatedDocs = await payload.db.findGlobalVersions<T>({
where: fullWhere,
page: page || 1,

View File

@@ -43,7 +43,7 @@ function buildMutationInputType(payload: Payload, name: string, fields: Field[],
const type = field.name === 'id' ? GraphQLInt : GraphQLFloat;
return {
...inputObjectTypeConfig,
[field.name]: { type: withNullableType(field, type, forceNullable) },
[field.name]: { type: withNullableType(field, field.hasMany === true ? new GraphQLList(type) : type, forceNullable) },
};
},
text: (inputObjectTypeConfig: InputObjectTypeConfig, field: TextField) => ({

View File

@@ -84,10 +84,13 @@ function buildObjectType({
forceNullable,
}: Args): GraphQLObjectType {
const fieldToSchemaMap = {
number: (objectTypeConfig: ObjectTypeConfig, field: NumberField) => ({
...objectTypeConfig,
[field.name]: { type: withNullableType(field, GraphQLFloat, forceNullable) },
}),
number: (objectTypeConfig: ObjectTypeConfig, field: NumberField) => {
const type = field?.name === 'id' ? GraphQLInt : GraphQLFloat;
return ({
...objectTypeConfig,
[field.name]: { type: withNullableType(field, field?.hasMany === true ? new GraphQLList(type) : type, forceNullable) },
});
},
text: (objectTypeConfig: ObjectTypeConfig, field: TextField) => ({
...objectTypeConfig,
[field.name]: { type: withNullableType(field, GraphQLString, forceNullable) },

View File

@@ -1,8 +1,8 @@
import { GraphQLBoolean, GraphQLInputObjectType, GraphQLString, GraphQLList, GraphQLFloat, GraphQLEnumType } from 'graphql';
import { GraphQLBoolean, GraphQLInputObjectType, GraphQLString, GraphQLList, GraphQLFloat, GraphQLEnumType, GraphQLInt } from 'graphql';
import type { GraphQLType } from 'graphql';
import { GraphQLJSON } from 'graphql-type-json';
import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars';
import { FieldAffectingData, RadioField, SelectField, optionIsObject } from '../../fields/config/types';
import { FieldAffectingData, NumberField, RadioField, RelationshipField, SelectField, optionIsObject } from '../../fields/config/types';
import combineParentName from '../utilities/combineParentName';
import formatName from '../utilities/formatName';
import operators from './operators';
@@ -27,7 +27,10 @@ type DefaultsType = {
const defaults: DefaultsType = {
number: {
type: GraphQLFloat,
type: (field: NumberField): GraphQLType => {
const type = field?.name === 'id' ? GraphQLInt : GraphQLFloat;
return field?.hasMany === true ? new GraphQLList(type) : type;
},
operators: [...operators.equality, ...operators.comparison],
},
text: {
@@ -86,7 +89,9 @@ const defaults: DefaultsType = {
operators: [...operators.equality, ...operators.comparison, ...operators.geo],
},
relationship: {
type: GraphQLString,
type: (field: RelationshipField): GraphQLType => {
return field?.hasMany === true ? new GraphQLList(GraphQLString) : GraphQLString;
},
operators: [...operators.equality, ...operators.contains],
},
upload: {

View File

View File

@@ -3,17 +3,31 @@ import type { MongooseAdapter } from '.';
import type { Find } from '../database/types';
import sanitizeInternalFields from '../utilities/sanitizeInternalFields';
import flattenWhereToOperators from '../database/flattenWhereToOperators';
import { buildSortParam } from './queries/buildSortParam';
export const find: Find = async function find(this: MongooseAdapter,
{ collection, where, page, limit, sort, locale, pagination }) {
export const find: Find = async function find(
this: MongooseAdapter,
{ collection, where, page, limit, sort: sortArg, locale, pagination },
) {
const Model = this.collections[collection];
const collectionConfig = this.payload.collections[collection].config;
let useEstimatedCount = false;
let hasNearConstraint = false;
if (where) {
const constraints = flattenWhereToOperators(where);
useEstimatedCount = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'));
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'));
}
let sort;
if (!hasNearConstraint) {
sort = buildSortParam({
sort: sortArg || collectionConfig.defaultSort,
fields: collectionConfig.fields,
timestamps: true,
config: this.payload.config,
locale,
});
}
const query = await Model.buildQuery({
@@ -24,14 +38,12 @@ export const find: Find = async function find(this: MongooseAdapter,
const paginationOptions: PaginateOptions = {
page,
sort: sort ? sort.reduce((acc, cur) => {
acc[cur.property] = cur.direction;
return acc;
}, {}) : undefined,
sort,
limit,
lean: true,
leanWithId: true,
useEstimatedCount,
useEstimatedCount: hasNearConstraint,
forceCountFn: hasNearConstraint,
pagination,
options: {
// limit must also be set here, it's ignored when pagination is false

View File

@@ -2,16 +2,30 @@ import type { MongooseAdapter } from '.';
import type { FindGlobalVersions } from '../database/types';
import sanitizeInternalFields from '../utilities/sanitizeInternalFields';
import flattenWhereToOperators from '../database/flattenWhereToOperators';
import { buildSortParam } from './queries/buildSortParam';
import { buildVersionGlobalFields } from '../versions/buildGlobalFields';
export const findGlobalVersions: FindGlobalVersions = async function findGlobalVersions(this: MongooseAdapter,
{ global, where, page, limit, sort, locale, pagination, skip }) {
{ global, where, page, limit, sort: sortArg, locale, pagination, skip }) {
const Model = this.versions[global];
const versionFields = buildVersionGlobalFields(this.payload.globals.config[global]);
let useEstimatedCount = false;
let hasNearConstraint = false;
if (where) {
const constraints = flattenWhereToOperators(where);
useEstimatedCount = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'));
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'));
}
let sort;
if (!hasNearConstraint) {
sort = buildSortParam({
sort: sortArg || '-updatedAt',
fields: versionFields,
timestamps: true,
config: this.payload.config,
locale,
});
}
const query = await Model.buildQuery({
@@ -23,16 +37,14 @@ export const findGlobalVersions: FindGlobalVersions = async function findGlobalV
const paginationOptions = {
page,
sort: sort ? sort.reduce((acc, cur) => {
acc[cur.property] = cur.direction;
return acc;
}, {}) : undefined,
sort,
limit,
lean: true,
leanWithId: true,
pagination,
offset: skip,
useEstimatedCount,
useEstimatedCount: hasNearConstraint,
forceCountFn: hasNearConstraint,
options: {
// limit must also be set here, it's ignored when pagination is false
limit,

View File

@@ -2,16 +2,29 @@ import type { MongooseAdapter } from '.';
import type { FindVersions } from '../database/types';
import sanitizeInternalFields from '../utilities/sanitizeInternalFields';
import flattenWhereToOperators from '../database/flattenWhereToOperators';
import { buildSortParam } from './queries/buildSortParam';
export const findVersions: FindVersions = async function findVersions(this: MongooseAdapter,
{ collection, where, page, limit, sort, locale, pagination, skip }) {
{ collection, where, page, limit, sort: sortArg, locale, pagination, skip }) {
const Model = this.versions[collection];
const collectionConfig = this.payload.collections[collection].config;
let useEstimatedCount = false;
let hasNearConstraint = false;
if (where) {
const constraints = flattenWhereToOperators(where);
useEstimatedCount = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'));
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'));
}
let sort;
if (!hasNearConstraint) {
sort = buildSortParam({
sort: sortArg || collectionConfig.defaultSort,
fields: collectionConfig.fields,
timestamps: true,
config: this.payload.config,
locale,
});
}
const query = await Model.buildQuery({
@@ -22,16 +35,14 @@ export const findVersions: FindVersions = async function findVersions(this: Mong
const paginationOptions = {
page,
sort: sort ? sort.reduce((acc, cur) => {
acc[cur.property] = cur.direction;
return acc;
}, {}) : undefined,
sort,
limit,
lean: true,
leanWithId: true,
pagination,
offset: skip,
useEstimatedCount,
useEstimatedCount: hasNearConstraint,
forceCountFn: hasNearConstraint,
options: {
// limit must also be set here, it's ignored when pagination is false
limit,

View File

@@ -114,7 +114,7 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema
const fieldToSchemaMap: Record<string, FieldSchemaGenerator> = {
number: (field: NumberField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => {
const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Number };
const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: field.hasMany ? [Number] : Number };
schema.add({
[field.name]: localizeSchema(field, baseSchema, config.localization),

View File

@@ -1,3 +1,5 @@
import mongoose from 'mongoose';
import objectID from 'bson-objectid';
import { Field, fieldAffectsData } from '../../fields/config/types';
import { operatorMap } from './operatorMap';
import { getLocalizedPaths } from './getLocalizedPaths';
@@ -63,7 +65,7 @@ export async function buildSearchParam({
field: {
name: 'id',
type: idFieldType,
},
} as Field,
complete: true,
collectionSlug,
});
@@ -127,7 +129,16 @@ export async function buildSearchParam({
const result = await SubModel.find(subQuery, subQueryOptions);
const $in = result.map((doc) => doc._id.toString());
const $in: unknown[] = [];
result.forEach((doc) => {
const stringID = doc._id.toString();
$in.push(stringID);
if (mongoose.Types.ObjectId.isValid(stringID)) {
$in.push(doc._id);
}
});
if (pathsToQuery.length === 1) {
return {
@@ -170,6 +181,57 @@ export async function buildSearchParam({
if (operator && validOperators.includes(operator)) {
const operatorKey = operatorMap[operator];
if (field.type === 'relationship' || field.type === 'upload') {
let hasNumberIDRelation;
const result = {
value: {
$or: [
{ [path]: { [operatorKey]: formattedValue } },
],
},
};
if (typeof formattedValue === 'string') {
if (mongoose.Types.ObjectId.isValid(formattedValue)) {
result.value.$or.push({ [path]: { [operatorKey]: objectID(formattedValue) } });
} else {
(Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo]).forEach((relationTo) => {
const isRelatedToCustomNumberID = payload.collections[relationTo]?.config?.fields.find((relatedField) => {
return fieldAffectsData(relatedField) && relatedField.name === 'id' && relatedField.type === 'number';
});
if (isRelatedToCustomNumberID) {
if (isRelatedToCustomNumberID.type === 'number') hasNumberIDRelation = true;
}
});
if (hasNumberIDRelation) result.value.$or.push({ [path]: { [operatorKey]: parseFloat(formattedValue) } });
}
}
if (result.value.$or.length > 1) {
return result;
}
}
if (operator === 'like' && typeof formattedValue === 'string') {
const words = formattedValue.split(' ');
const result = {
value: {
$and: words.map((word) => ({
[path]: {
$regex: word.replace(/[\\^$*+?\\.()|[\]{}]/g, '\\$&'),
$options: 'i',
},
})),
},
};
return result;
}
// Some operators like 'near' need to define a full query
// so if there is no operator key, just return the value
if (!operatorKey) {

View File

@@ -1,7 +1,7 @@
import { PaginateOptions } from 'mongoose';
import { Config } from '../../config/types';
import { getLocalizedSortProperty } from './getLocalizedSortProperty';
import { Field } from '../../fields/config/types';
import type { SortArgs, SortDirection } from '../../database/types';
type Args = {
sort: string
@@ -11,7 +11,14 @@ type Args = {
locale: string
}
export const buildSortParam = ({ sort, config, fields, timestamps, locale }: Args): SortArgs => {
export type SortArgs = {
property: string
direction: SortDirection
}[]
export type SortDirection = 'asc' | 'desc';
export const buildSortParam = ({ sort, config, fields, timestamps, locale }: Args): PaginateOptions['sort'] => {
let sortProperty: string;
let sortDirection: SortDirection = 'desc';
@@ -39,5 +46,5 @@ export const buildSortParam = ({ sort, config, fields, timestamps, locale }: Arg
});
}
return [{ property: sortProperty, direction: sortDirection }];
return { [sortProperty]: sortDirection };
};

View File

@@ -1,6 +1,5 @@
import mongoose from 'mongoose';
import { createArrayFromCommaDelineated } from '../../utilities/createArrayFromCommaDelineated';
import wordBoundariesRegex from '../../utilities/wordBoundariesRegex';
import { Field, TabAsField } from '../../fields/config/types';
type SanitizeQueryValueArgs = {
@@ -39,7 +38,15 @@ export const sanitizeQueryValue = ({ field, path, operator, val, hasCustomID }:
if (val.toLowerCase() === 'false') formattedValue = false;
}
if (field.type === 'number' && typeof val === 'string') {
if (['all', 'not_in', 'in'].includes(operator) && typeof formattedValue === 'string') {
formattedValue = createArrayFromCommaDelineated(formattedValue);
if (field.type === 'number') {
formattedValue = formattedValue.map((arrayVal) => parseFloat(arrayVal));
}
}
if (field.type === 'number' && typeof formattedValue === 'string') {
formattedValue = Number(val);
}
@@ -99,19 +106,10 @@ export const sanitizeQueryValue = ({ field, path, operator, val, hasCustomID }:
}
}
if (['all', 'not_in', 'in'].includes(operator) && typeof formattedValue === 'string') {
formattedValue = createArrayFromCommaDelineated(formattedValue);
}
if (path !== '_id' || (path === '_id' && hasCustomID && field.type === 'text')) {
if (operator === 'contains') {
formattedValue = { $regex: formattedValue, $options: 'i' };
}
if (operator === 'like' && typeof formattedValue === 'string') {
const $regex = wordBoundariesRegex(formattedValue);
formattedValue = { $regex };
}
}
if (operator === 'exists') {

View File

@@ -2,6 +2,7 @@ import type { MongooseAdapter } from '.';
import type { QueryDrafts } from '../database/types';
import flattenWhereToOperators from '../database/flattenWhereToOperators';
import sanitizeInternalFields from '../utilities/sanitizeInternalFields';
import { buildSortParam } from './queries/buildSortParam';
type AggregateVersion<T> = {
_id: string
@@ -11,8 +12,9 @@ type AggregateVersion<T> = {
}
export const queryDrafts: QueryDrafts = async function queryDrafts<T>(this: MongooseAdapter,
{ collection, where, page, limit, sort, locale, pagination }) {
{ collection, where, page, limit, sort: sortArg, locale, pagination }) {
const VersionModel = this.versions[collection];
const collectionConfig = this.payload.collections[collection].config;
const versionQuery = await VersionModel.buildQuery({
where,
@@ -20,6 +22,24 @@ export const queryDrafts: QueryDrafts = async function queryDrafts<T>(this: Mong
payload: this.payload,
});
let hasNearConstraint = false;
if (where) {
const constraints = flattenWhereToOperators(where);
hasNearConstraint = constraints.some((prop) => Object.keys(prop).some((key) => key === 'near'));
}
let sort;
if (!hasNearConstraint) {
sort = buildSortParam({
sort: sortArg || collectionConfig.defaultSort,
fields: collectionConfig.fields,
timestamps: true,
config: this.payload.config,
locale,
});
}
const aggregate = VersionModel.aggregate<AggregateVersion<T>>([
// Sort so that newest are first
{ $sort: { updatedAt: -1 } },
@@ -61,16 +81,7 @@ export const queryDrafts: QueryDrafts = async function queryDrafts<T>(this: Mong
options: {
limit,
},
sort: sort ? sort.reduce((acc, cur) => {
let sanitizedSortProperty = cur.property;
const sanitizedSortOrder = cur.direction === 'asc' ? 1 : -1;
if (!['createdAt', 'updatedAt', '_id'].includes(cur.property)) {
sanitizedSortProperty = `version.${cur.property}`;
}
acc[sanitizedSortProperty] = sanitizedSortOrder;
return acc;
}, {}) : undefined,
sort,
};
result = await VersionModel.aggregatePaginate(aggregate, aggregatePaginateOptions);

View File

@@ -241,6 +241,7 @@
"uploading": "جار الرفع",
"user": "المستخدم",
"users": "المستخدمين",
"value": "القيمة",
"welcome": "مرحبًا"
},
"operators": {
@@ -274,11 +275,11 @@
"emailAddress": "يرجى إدخال عنوان بريد إلكتروني صحيح.",
"enterNumber": "يرجى إدخال رقم صحيح.",
"fieldHasNo": "هذا الحقل ليس لديه {{label}}",
"greaterThanMax": "\"{{value}}\" أكبر من الحد الأقصى المسموح به {{max}}.",
"greaterThanMax": "{{value}} أكبر من الحد الأقصى المسموح به {{label}} الذي يبلغ {{max}}.",
"invalidInput": "هذا الحقل لديه إدخال غير صالح.",
"invalidSelection": "هذا الحقل لديه اختيار غير صالح.",
"invalidSelections": "هذا الحقل لديه الاختيارات الغير صالحة التالية:",
"lessThanMin": "\"{{value}}\" أقل من الحد الأدنى المسموح به {{min}}.",
"lessThanMin": "{{value}} أقل من الحد الأدنى المسموح به {{label}} الذي يبلغ {{min}}.",
"longerThanMin": "يجب أن يكون هذا القيمة أطول من الحد الأدنى للطول الذي هو {{minLength}} أحرف.",
"notValidDate": "\"{{value}}\" ليس تاريخا صالحا.",
"required": "هذا الحقل مطلوب.",

View File

@@ -241,6 +241,7 @@
"uploading": "Качва се",
"user": "Потребител",
"users": "Потребители",
"value": "Стойност",
"welcome": "Добре дошъл"
},
"operators": {
@@ -274,11 +275,11 @@
"emailAddress": "Моля, въведи валиден имейл адрес.",
"enterNumber": "Моля, въведи валиден номер.",
"fieldHasNo": "Това поле няма {{label}}",
"greaterThanMax": "\"{{value}}\" е по-голямо от максималната позволена големина {{max}}.",
"greaterThanMax": "{{value}} е по-голямо от максимално допустимото {{label}} от {{max}}.",
"invalidInput": "Това поле има невалиден вход.",
"invalidSelection": "Това поле има невалидна селекция.",
"invalidSelections": "Това поле има следните невалидни селекции:",
"lessThanMin": "\"{{value}}\" е по-малко от минималната позволена големина {{min}}.",
"lessThanMin": "{{value}} е по-малко от минимално допустимото {{label}} от {{min}}.",
"longerThanMin": "Тази стойност трябва да е по-голяма от минималната стойност от {{minLength}} символа.",
"notValidDate": "\"{{value}}\" не е валидна дата.",
"required": "Това поле е задължително.",

View File

@@ -238,6 +238,7 @@
"uploading": "Nahrávání",
"user": "Uživatel",
"users": "Uživatelé",
"value": "Hodnota",
"welcome": "Vítejte"
},
"operators": {
@@ -271,11 +272,11 @@
"emailAddress": "Zadejte prosím platnou e-mailovou adresu.",
"enterNumber": "Zadejte prosím platné číslo.",
"fieldHasNo": "Toto pole nemá {{label}}",
"greaterThanMax": "\"{{value}}\" je větší než maximální povolená hodnota {{max}}.",
"greaterThanMax": "{{value}} je vší než maximálně povolená {{label}} {{max}}.",
"invalidInput": "Toto pole má neplatný vstup.",
"invalidSelection": "Toto pole má neplatný výběr.",
"invalidSelections": "Toto pole má následující neplatné výběry:",
"lessThanMin": "\"{{value}}\" je menší než minimální povolená hodnota {{min}}.",
"lessThanMin": "{{value}} je nižší než minimálně povolená {{label}} {{min}}.",
"longerThanMin": "Tato hodnota musí být delší než minimální délka {{minLength}} znaků.",
"notValidDate": "\"{{value}}\" není platné datum.",
"required": "Toto pole je povinné.",

View File

@@ -241,6 +241,7 @@
"uploading": "Hochladen",
"user": "Benutzer",
"users": "Benutzer",
"value": "Wert",
"welcome": "Willkommen"
},
"operators": {
@@ -274,11 +275,11 @@
"emailAddress": "Bitte gib eine korrekte E-Mail-Adresse an.",
"enterNumber": "Bitte gib eine gültige Nummer an,",
"fieldHasNo": "Dieses Feld hat kein {{label}}",
"greaterThanMax": "\"{{value}}\" ist größer als der maximal erlaubte Wert von {{max}}.",
"greaterThanMax": "{{value}} ist größer als der maximal erlaubte {{label}} von {{max}}.",
"invalidInput": "Dieses Feld hat einen inkorrekten Wert.",
"invalidSelection": "Dieses Feld hat eine inkorrekte Auswahl.",
"invalidSelections": "'Dieses Feld enthält die folgenden inkorrekten Auswahlen:'",
"lessThanMin": "\"{{value}}\" ist weniger als der minimale erlaubte Wert von {{min}}.",
"lessThanMin": "{{value}} ist kleiner als der minimal erlaubte {{label}} von {{min}}.",
"longerThanMin": "Dieser Wert muss länger als die minimale Länge von {{minLength}} Zeichen sein.",
"notValidDate": "\"{{value}}\" ist kein gültiges Datum.",
"required": "Pflichtfeld",

View File

@@ -241,6 +241,7 @@
"uploading": "Uploading",
"user": "User",
"users": "Users",
"value": "Value",
"welcome": "Welcome"
},
"operators": {
@@ -274,11 +275,11 @@
"emailAddress": "Please enter a valid email address.",
"enterNumber": "Please enter a valid number.",
"fieldHasNo": "This field has no {{label}}",
"greaterThanMax": "\"{{value}}\" is greater than the max allowed value of {{max}}.",
"greaterThanMax": "{{value}} is greater than the max allowed {{label}} of {{max}}.",
"invalidInput": "This field has an invalid input.",
"invalidSelection": "This field has an invalid selection.",
"invalidSelections": "This field has the following invalid selections:",
"lessThanMin": "\"{{value}}\" is less than the min allowed value of {{min}}.",
"lessThanMin": "{{value}} is less than the min allowed {{label}} of {{min}}.",
"longerThanMin": "This value must be longer than the minimum length of {{minLength}} characters.",
"notValidDate": "\"{{value}}\" is not a valid date.",
"required": "This field is required.",

View File

@@ -241,6 +241,7 @@
"uploading": "Subiendo",
"user": "Usuario",
"users": "Usuarios",
"value": "Valor",
"welcome": "Bienvenido"
},
"operators": {
@@ -274,11 +275,11 @@
"emailAddress": "Por favor introduce un correo electrónico válido.",
"enterNumber": "Por favor introduce un número válido.",
"fieldHasNo": "Este campo no tiene {{label}}",
"greaterThanMax": "\"{{value}}\" es mayor que el valor máximo permitido de {{max}}.",
"greaterThanMax": "{{value}} es mayor que el {{label}} máximo permitido de {{max}}.",
"invalidInput": "La información en este campo es inválida.",
"invalidSelection": "La selección en este campo es inválida.",
"invalidSelections": "Este campo tiene las siguientes selecciones inválidas:",
"lessThanMin": "\"{{value}}\" es menor que el valor mínimo permitido de {{min}}.",
"lessThanMin": "{{value}} es menor que el {{label}} mínimo permitido de {{min}}.",
"longerThanMin": "Este dato debe ser más largo que el mínimo de {{minLength}} caracteres.",
"notValidDate": "\"{{value}}\" es una fecha inválida.",
"required": "Este campo es obligatorio.",

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