wip merge master
This commit is contained in:
2
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/1.bug_report.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -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 }}
|
||||
|
||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
```
|
||||
|
||||
@@ -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';
|
||||
```
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
104
docs/integrations/vercel-visual-editing.mdx
Normal file
104
docs/integrations/vercel-visual-editing.mdx
Normal 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.
|
||||
|
||||

|
||||
|
||||
<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.
|
||||
|
||||

|
||||
|
||||
### 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>
|
||||
```
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
10
package.json
10
package.json
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
.value-container {
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
|
||||
.rs__value-container {
|
||||
padding: base(.25) 0;
|
||||
|
||||
@@ -8,7 +8,9 @@ const baseClass = 'value-container';
|
||||
|
||||
export const ValueContainer: React.FC<ValueContainerProps<Option, any>> = (props) => {
|
||||
const {
|
||||
customProps,
|
||||
selectProps: {
|
||||
customProps,
|
||||
} = {},
|
||||
} = props;
|
||||
|
||||
return (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
position: relative;
|
||||
margin-bottom: $baseline;
|
||||
|
||||
input {
|
||||
@include formInput;
|
||||
&:not(.has-many) {
|
||||
input {
|
||||
@include formInput;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -17,10 +17,12 @@ export const MultiValueLabel: React.FC<MultiValueProps<Option>> = (props) => {
|
||||
relationTo,
|
||||
label,
|
||||
},
|
||||
customProps: {
|
||||
setDrawerIsOpen,
|
||||
draggableProps,
|
||||
onSave,
|
||||
selectProps: {
|
||||
customProps: {
|
||||
setDrawerIsOpen,
|
||||
draggableProps,
|
||||
onSave,
|
||||
} = {},
|
||||
} = {},
|
||||
} = props;
|
||||
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
|
||||
.relationship--single-value {
|
||||
&.rs__single-value {
|
||||
overflow: visible;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__label-text {
|
||||
max-width: unset;
|
||||
display: flex;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
57
src/admin/components/forms/withCondition/WatchCondition.tsx
Normal file
57
src/admin/components/forms/withCondition/WatchCondition.tsx
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
]);
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
@include mid-break {
|
||||
--gutter-h: #{base(2)};
|
||||
--nav-width: 0;
|
||||
--nav-width: 0px;
|
||||
}
|
||||
|
||||
@include small-break {
|
||||
|
||||
@@ -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() } },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() } },
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EmailOptions } from '../config/types';
|
||||
|
||||
export const defaults: EmailOptions = {
|
||||
fromName: 'Payload CMS',
|
||||
fromName: 'Payload',
|
||||
fromAddress: 'info@payloadcms.com',
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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') });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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: {
|
||||
|
||||
0
src/mongoose/buildQuery.ts
Normal file
0
src/mongoose/buildQuery.ts
Normal 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": "هذا الحقل مطلوب.",
|
||||
|
||||
@@ -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": "Това поле е задължително.",
|
||||
|
||||
@@ -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 vyšší 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é.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user