Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d148d7309 | ||
|
|
8e10ecae4b | ||
|
|
ef27b9f641 | ||
|
|
2dcce0339c | ||
|
|
b649ad7bb5 | ||
|
|
3cee0be314 | ||
|
|
8fc953605a | ||
|
|
e28dfc0c93 | ||
|
|
33561a8ea2 | ||
|
|
e500b46576 | ||
|
|
e79a84d200 | ||
|
|
16e94d401b | ||
|
|
9fbabc8fd6 | ||
|
|
9bc072ccaf | ||
|
|
45905c312f | ||
|
|
2c7a15ceca | ||
|
|
28e128241e | ||
|
|
21336cd61a | ||
|
|
ff1c10c382 | ||
|
|
6e8aa5e8af | ||
|
|
32e7c56a0d | ||
|
|
e5c783df5d | ||
|
|
63698e5e88 | ||
|
|
016ad3afc9 | ||
|
|
33e1e15ca9 | ||
|
|
59918aac91 | ||
|
|
a0c3cbd68d | ||
|
|
3a15e077c6 | ||
|
|
90a9e14e9d | ||
|
|
6d3b8636f4 | ||
|
|
cb8e07f852 | ||
|
|
301be0a5d4 | ||
|
|
466589b483 | ||
|
|
6754f55ce0 | ||
|
|
84d2bacb56 | ||
|
|
739abdcd81 | ||
|
|
c7cf2d3d2c | ||
|
|
79561497f9 | ||
|
|
600306274e | ||
|
|
398378a867 | ||
|
|
4e755dfde2 | ||
|
|
3634e2cc4d | ||
|
|
294fb5e574 | ||
|
|
f5f2332755 | ||
|
|
0acd7b8706 | ||
|
|
d91b44cbb3 | ||
|
|
e03a8e6b03 | ||
|
|
846485388a | ||
|
|
8d83e05948 | ||
|
|
7963d04a27 | ||
|
|
20b6b29c79 | ||
|
|
fdfdfc83f3 | ||
|
|
c154eb7e2b | ||
|
|
33686c6db8 | ||
|
|
6d6acbcfc1 | ||
|
|
4e2f2561ff |
9
.vscode/launch.json
vendored
9
.vscode/launch.json
vendored
@@ -18,5 +18,12 @@
|
||||
"type": "node-terminal",
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"command": "yarn run dev versions",
|
||||
"name": "Debug Versions",
|
||||
"request": "launch",
|
||||
"type": "node-terminal",
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
53
CHANGELOG.md
53
CHANGELOG.md
@@ -1,5 +1,58 @@
|
||||
|
||||
|
||||
## [1.15.3](https://github.com/payloadcms/payload/compare/v1.15.2...v1.15.3) (2023-09-05)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* draft globals always displaying unpublish button ([9bc072c](https://github.com/payloadcms/payload/commit/9bc072ccaf318c61b2c4e2a553604a24ff6a188e))
|
||||
* globals not saving updatedAt and createdAt and version dates correctly ([9fbabc8](https://github.com/payloadcms/payload/commit/9fbabc8fd6a3bea5628bea8d0acc915ddb33bb5c))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* improves query speed for version enabled collections ([16e94d4](https://github.com/payloadcms/payload/commit/16e94d401bd7cb82de53142c5f9a325abd31a81a))
|
||||
|
||||
## [1.15.2](https://github.com/payloadcms/payload/compare/v1.15.1...v1.15.2) (2023-08-25)
|
||||
|
||||
## [1.15.1](https://github.com/payloadcms/payload/compare/v1.15.0...v1.15.1) (2023-08-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* arrays in richtext uploads ([#3222](https://github.com/payloadcms/payload/issues/3222)) ([cb8e07f](https://github.com/payloadcms/payload/commit/cb8e07f85232a26c265872faf408644424312af6))
|
||||
* correct out of order dark-mode color variables ([#3197](https://github.com/payloadcms/payload/issues/3197)) ([3a15e07](https://github.com/payloadcms/payload/commit/3a15e077c6914aba3ef26e453fee23c89f3db829))
|
||||
* mutation type with tabs missing previous tabs ([#3196](https://github.com/payloadcms/payload/issues/3196)) ([6d3b863](https://github.com/payloadcms/payload/commit/6d3b8636f4e14a4e4155279353fa06e86fe2b25c))
|
||||
|
||||
# [1.15.0](https://github.com/payloadcms/payload/compare/v1.14.0...v1.15.0) (2023-08-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* query support for geo within and intersects + dynamic GraphQL operator types ([#3183](https://github.com/payloadcms/payload/issues/3183)) ([739abdc](https://github.com/payloadcms/payload/commit/739abdcd81176b3e812470eeea97b1be0d8c4a27))
|
||||
|
||||
# [1.14.0](https://github.com/payloadcms/payload/compare/v1.13.4...v1.14.0) (2023-08-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* DatePicker showing only selected day by default ([#3169](https://github.com/payloadcms/payload/issues/3169)) ([edcb393](https://github.com/payloadcms/payload/commit/edcb3933cfb4532180c822135ea6a8be928e0fdc))
|
||||
* only allow redirects to /admin sub-routes ([c0f05a1](https://github.com/payloadcms/payload/commit/c0f05a1c38fb9c958de920fabb698b5ecfb661f0))
|
||||
* passes in height to resizeOptions upload option to allow height resize ([#3171](https://github.com/payloadcms/payload/issues/3171)) ([7963d04](https://github.com/payloadcms/payload/commit/7963d04a27888eb5a12d0ab37f2082cd33638abd))
|
||||
* WhereBuilder component does not accept all valid Where queries ([#3087](https://github.com/payloadcms/payload/issues/3087)) ([fdfdfc8](https://github.com/payloadcms/payload/commit/fdfdfc83f36a958971f8e4e4f9f5e51560cb26e0))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add afterOperation hook ([#2697](https://github.com/payloadcms/payload/issues/2697)) ([33686c6](https://github.com/payloadcms/payload/commit/33686c6db8373a16d7f6b0192e0701bf15881aa4))
|
||||
* add support for hotkeys ([#1821](https://github.com/payloadcms/payload/issues/1821)) ([942cfec](https://github.com/payloadcms/payload/commit/942cfec286ff050e13417b037cca64b9d757d868))
|
||||
* Added Azerbaijani language file ([#3164](https://github.com/payloadcms/payload/issues/3164)) ([63e3063](https://github.com/payloadcms/payload/commit/63e3063b9ecc1afd62d7a287a798d41215008f2a))
|
||||
* allow async relationship filter options ([#2951](https://github.com/payloadcms/payload/issues/2951)) ([bad3638](https://github.com/payloadcms/payload/commit/bad363882c9d00d3c73547ca3329eba988e728ff))
|
||||
* Improve admin dashboard accessibility ([#3053](https://github.com/payloadcms/payload/issues/3053)) ([e03a8e6](https://github.com/payloadcms/payload/commit/e03a8e6b030e82a17e1cdae5b4032433cf9c75a4))
|
||||
* improve field ops ([#3172](https://github.com/payloadcms/payload/issues/3172)) ([d91b44c](https://github.com/payloadcms/payload/commit/d91b44cbb3fd526caca2a6f4bd30fd06ede3a5da))
|
||||
* make PAYLOAD_CONFIG_PATH optional ([#2839](https://github.com/payloadcms/payload/issues/2839)) ([5744de7](https://github.com/payloadcms/payload/commit/5744de7ec63e3f17df7e02a7cc827818a79dbbb8))
|
||||
* text alignment for richtext editor ([#2803](https://github.com/payloadcms/payload/issues/2803)) ([a0b13a5](https://github.com/payloadcms/payload/commit/a0b13a5b01fa0d7f4c4dffd1895bfe507e5c676d))
|
||||
|
||||
## [1.13.4](https://github.com/payloadcms/payload/compare/v1.13.3...v1.13.4) (2023-08-11)
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ Payload documentation can be found directly within its codebase and you can feel
|
||||
|
||||
If you're an incredibly awesome person and want to help us make Payload even better through new features or additions, we would be thrilled to work with you.
|
||||
|
||||
## Design Contributions
|
||||
|
||||
When it comes to design-related changes or additions, it's crucial for us to ensure a cohesive user experience and alignment with our broader design vision. Before embarking on any implementation that would affect the design or UI/UX, we ask that you **first share your design proposal** with us for review and approval.
|
||||
|
||||
Our design review ensures that proposed changes fit seamlessly with other components, both existing and planned. This step is meant to prevent unintentional design inconsistencies and to save you from investing time in implementing features that might need significant design alterations later.
|
||||
|
||||
### Before Starting
|
||||
|
||||
To help us work on new features, you can create a new feature request post in [GitHub Discussion](https://github.com/payloadcms/payload/discussions) or discuss it in our [Discord](https://discord.com/invite/payload). New functionality often has large implications across the entire Payload repo, so it is best to discuss the architecture and approach before starting work on a pull request.
|
||||
|
||||
@@ -31,8 +31,10 @@ keywords: array, fields, config, configuration, documentation, Content Managemen
|
||||
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`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. |
|
||||
| **`fields`** \* | Array of field types to correspond to each row of the Array. |
|
||||
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) |
|
||||
| **`fields`** \* | Array of field types to correspond to each row of the Array. |
|
||||
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation)
|
||||
| **`minRows`** | A number for the fewest allowed items during validation when a value is present. |
|
||||
| **`maxRows`** | A number for the most allowed items during validation when a value is present. |
|
||||
| **`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-based hooks to control logic for this field. [More](/docs/fields/overview#field-level-hooks) |
|
||||
| **`access`** | Provide field-based access control to denote what users can see and do with this field's data. [More](/docs/fields/overview#field-level-access-control) |
|
||||
|
||||
@@ -33,7 +33,9 @@ keywords: blocks, fields, config, configuration, documentation, Content Manageme
|
||||
| **`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) |
|
||||
| **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation)
|
||||
| **`minRows`** | A number for the fewest allowed items during validation when a value is present. |
|
||||
| **`maxRows`** | A number for the most allowed items during validation when a value is present. |
|
||||
| **`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) |
|
||||
|
||||
@@ -7,9 +7,7 @@ keywords: rich text, fields, config, configuration, documentation, Content Manag
|
||||
---
|
||||
|
||||
<Banner>
|
||||
The Rich Text field is a powerful way to allow editors to write dynamic
|
||||
content. The content is saved as JSON in the database and can be converted
|
||||
into any format, including HTML, that you need.
|
||||
The Rich Text field is a powerful way to allow editors to write dynamic content. The content is saved as JSON in the database and can be converted into any format, including HTML, that you need.
|
||||
</Banner>
|
||||
|
||||
<LightDarkImage
|
||||
@@ -22,14 +20,7 @@ keywords: rich text, fields, config, configuration, documentation, Content Manag
|
||||
The Admin component is built on the powerful [`slatejs`](https://docs.slatejs.org/) editor and is meant to be as extensible and customizable as possible.
|
||||
|
||||
<Banner type="success">
|
||||
<strong>
|
||||
Consistent with Payload's goal of making you learn as little of Payload as
|
||||
possible, customizing and using the Rich Text Editor does not involve
|
||||
learning how to develop for a <em>Payload</em> rich text editor.
|
||||
</strong>{" "}
|
||||
Instead, you can invest your time and effort into learning Slate, an
|
||||
open-source tool that will allow you to apply your learnings elsewhere as
|
||||
well.
|
||||
<strong>Consistent with Payload's goal of making you learn as little of Payload as possible, customizing and using the Rich Text Editor does not involve learning how to develop for a <em>Payload</em> rich text editor.</strong> Instead, you can invest your time and effort into learning Slate, an open-source tool that will allow you to apply your learnings elsewhere as well.
|
||||
</Banner>
|
||||
|
||||
### Config
|
||||
@@ -125,13 +116,7 @@ The built-in `relationship` element is a powerful way to reference other Documen
|
||||
Similar to the `relationship` element, the `upload` element is a user-friendly way to reference [Upload-enabled collections](/docs/upload/overview) with a UI specifically designed for media / image-based uploads.
|
||||
|
||||
<Banner type="success">
|
||||
<strong>Tip:</strong>
|
||||
<br />
|
||||
Collections are automatically allowed to be selected within the Rich Text
|
||||
relationship and upload elements by default. If you want to disable a
|
||||
collection from being able to be referenced in Rich Text fields, set the
|
||||
collection admin options of <strong>enableRichTextLink</strong> and{" "}
|
||||
<strong>enableRichTextRelationship</strong> to false.
|
||||
<strong>Tip:</strong><br />Collections are automatically allowed to be selected within the Rich Text relationship and upload elements by default. If you want to disable a collection from being able to be referenced in Rich Text fields, set the collection admin options of <strong>enableRichTextLink</strong> and <strong>enableRichTextRelationship</strong> to false.
|
||||
</Banner>
|
||||
|
||||
Relationship and Upload elements are populated dynamically into your Rich Text field' content. Within the REST and Local APIs, any present RichText `relationship` or `upload` elements will respect the `depth` option that you pass, and will be populated accordingly. In GraphQL, each `richText` field accepts an argument of `depth` for you to utilize.
|
||||
@@ -307,10 +292,7 @@ const serialize = (children) =>
|
||||
```
|
||||
|
||||
<Banner>
|
||||
<strong>Note:</strong>
|
||||
<br />
|
||||
The above example is for how to render to JSX, although for plain HTML the
|
||||
pattern is similar. Just remove the JSX and return HTML strings instead!
|
||||
<strong>Note:</strong><br />The above example is for how to render to JSX, although for plain HTML the pattern is similar. Just remove the JSX and return HTML strings instead!
|
||||
</Banner>
|
||||
|
||||
### Built-in SlateJS Plugins
|
||||
|
||||
@@ -10,8 +10,7 @@ keywords: select, multi-select, fields, config, configuration, documentation, Co
|
||||
The Select field provides a dropdown-style interface for choosing options from
|
||||
a predefined list as an enumeration.
|
||||
</Banner>
|
||||
|
||||
<LightDarkImage
|
||||
<LightDarkImage
|
||||
srcLight='https://payloadcms.com/images/docs/fields/select.png'
|
||||
srcDark='https://payloadcms.com/images/docs/fields/select-dark.png'
|
||||
alt='Shows a Select field in the Payload admin panel'
|
||||
@@ -99,3 +98,85 @@ export const ExampleCollection: CollectionConfig = {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### Customization
|
||||
|
||||
The Select field UI component can be customized by providing a custom React component to the `components` object in the Base config.
|
||||
|
||||
```ts
|
||||
export const CustomSelectField: Field = {
|
||||
name: 'customSelectField',
|
||||
type: 'select', // or 'text' if you have dynamic options
|
||||
admin: {
|
||||
components: {
|
||||
Field: CustomSelectComponent({
|
||||
options: [
|
||||
{
|
||||
label: 'Option 1',
|
||||
value: '1',
|
||||
},
|
||||
{
|
||||
label: 'Option 2',
|
||||
value: '2',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can import the existing Select component directly from Payload, then extend and customize it as needed.
|
||||
|
||||
```ts
|
||||
import * as React from 'react';
|
||||
import { SelectInput, useField } from 'payload/components/forms';
|
||||
import { useAuth } from 'payload/components/utilities';
|
||||
|
||||
type customSelectProps = {
|
||||
path: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const CustomSelectComponent: React.FC<CustomSelectProps> = ({ path, options }) => {
|
||||
const { value, setValue } = useField<string>({ path });
|
||||
const { user } = useAuth();
|
||||
|
||||
const adjustedOptions = options.filter((option) => {
|
||||
/*
|
||||
A common use case for a custom select
|
||||
is to show different options based on
|
||||
the current user's role.
|
||||
*/
|
||||
return option;
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="field-label">
|
||||
Custom Select
|
||||
</label>
|
||||
<SelectInput
|
||||
path={path}
|
||||
name={path}
|
||||
options={adjustedOptions}
|
||||
value={value}
|
||||
onChange={() => setValue(e.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
If you are looking to create a dynamic select field, the following tutorial will walk you through the process of creating a custom select field that fetches its options from an external API.
|
||||
|
||||
<VideoDrawer
|
||||
id='Efn9OxSjA6Y'
|
||||
label='How to Create a Custom Select Field'
|
||||
drawerTitle='How to Create a Custom Select Field: A Step-by-Step Guide'
|
||||
/>
|
||||
|
||||
If you want to learn more about custom components check out the [Admin > Custom Component](/docs/admin/components#field-component) docs.
|
||||
|
||||
@@ -16,6 +16,7 @@ Collections feature the ability to define the following hooks:
|
||||
- [afterRead](#afterread)
|
||||
- [beforeDelete](#beforedelete)
|
||||
- [afterDelete](#afterdelete)
|
||||
- [afterOperation](#afteroperation)
|
||||
|
||||
Additionally, `auth`-enabled collections feature the following hooks:
|
||||
|
||||
@@ -31,6 +32,7 @@ Additionally, `auth`-enabled collections feature the following hooks:
|
||||
All collection Hook properties accept arrays of synchronous or asynchronous functions. Each Hook type receives specific arguments and has the ability to modify specific outputs.
|
||||
|
||||
`collections/exampleHooks.js`
|
||||
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
@@ -48,6 +50,7 @@ export const ExampleHooks: CollectionConfig = {
|
||||
afterChange: [(args) => {...}],
|
||||
afterRead: [(args) => {...}],
|
||||
afterDelete: [(args) => {...}],
|
||||
afterOperation: [(args) => {...}],
|
||||
|
||||
// Auth-enabled hooks
|
||||
beforeLogin: [(args) => {...}],
|
||||
@@ -62,19 +65,19 @@ export const ExampleHooks: CollectionConfig = {
|
||||
|
||||
### beforeOperation
|
||||
|
||||
The `beforeOperation` Hook type can be used to modify the arguments that operations accept or execute side-effects that run before an operation begins.
|
||||
The `beforeOperation` hook can be used to modify the arguments that operations accept or execute side-effects that run before an operation begins.
|
||||
|
||||
Available Collection operations include `create`, `read`, `update`, `delete`, `login`, `refresh` and `forgotPassword`.
|
||||
Available Collection operations include `create`, `read`, `update`, `delete`, `login`, `refresh`, and `forgotPassword`.
|
||||
|
||||
```ts
|
||||
import { CollectionBeforeOperationHook } from 'payload/types';
|
||||
import { CollectionBeforeOperationHook } from "payload/types";
|
||||
|
||||
const beforeOperationHook: CollectionBeforeOperationHook = async ({
|
||||
args, // Original arguments passed into the operation
|
||||
args, // original arguments passed into the operation
|
||||
operation, // name of the operation
|
||||
}) => {
|
||||
return args; // Return operation arguments as necessary
|
||||
}
|
||||
return args; // return modified operation arguments as necessary
|
||||
};
|
||||
```
|
||||
|
||||
### beforeValidate
|
||||
@@ -88,7 +91,7 @@ Please do note that this does not run before the client-side validation. If you
|
||||
3. `validate` runs on the server
|
||||
|
||||
```ts
|
||||
import { CollectionBeforeOperationHook } from 'payload/types';
|
||||
import { CollectionBeforeOperationHook } from "payload/types";
|
||||
|
||||
const beforeValidateHook: CollectionBeforeValidateHook = async ({
|
||||
data, // incoming data to update or create with
|
||||
@@ -97,7 +100,7 @@ const beforeValidateHook: CollectionBeforeValidateHook = async ({
|
||||
originalDoc, // original document
|
||||
}) => {
|
||||
return data; // Return data to either create or update a document with
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### beforeChange
|
||||
@@ -105,7 +108,7 @@ const beforeValidateHook: CollectionBeforeValidateHook = async ({
|
||||
Immediately following validation, `beforeChange` hooks will run within `create` and `update` operations. At this stage, you can be confident that the data that will be saved to the document is valid in accordance to your field validations. You can optionally modify the shape of data to be saved.
|
||||
|
||||
```ts
|
||||
import { CollectionBeforeChangeHook } from 'payload/types';
|
||||
import { CollectionBeforeChangeHook } from "payload/types";
|
||||
|
||||
const beforeChangeHook: CollectionBeforeChangeHook = async ({
|
||||
data, // incoming data to update or create with
|
||||
@@ -114,7 +117,7 @@ const beforeChangeHook: CollectionBeforeChangeHook = async ({
|
||||
originalDoc, // original document
|
||||
}) => {
|
||||
return data; // Return data to either create or update a document with
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### afterChange
|
||||
@@ -122,7 +125,7 @@ const beforeChangeHook: CollectionBeforeChangeHook = async ({
|
||||
After a document is created or updated, the `afterChange` hook runs. This hook is helpful to recalculate statistics such as total sales within a global, syncing user profile changes to a CRM, and more.
|
||||
|
||||
```ts
|
||||
import { CollectionAfterChangeHook } from 'payload/types';
|
||||
import { CollectionAfterChangeHook } from "payload/types";
|
||||
|
||||
const afterChangeHook: CollectionAfterChangeHook = async ({
|
||||
doc, // full document data
|
||||
@@ -130,8 +133,8 @@ const afterChangeHook: CollectionAfterChangeHook = async ({
|
||||
previousDoc, // document data before updating the collection
|
||||
operation, // name of the operation ie. 'create', 'update'
|
||||
}) => {
|
||||
return doc;
|
||||
}
|
||||
return doc; // value to be used in subsequent afterChange hooks
|
||||
};
|
||||
```
|
||||
|
||||
### beforeRead
|
||||
@@ -139,7 +142,7 @@ const afterChangeHook: CollectionAfterChangeHook = async ({
|
||||
Runs before `find` and `findByID` operations are transformed for output by `afterRead`. This hook fires before hidden fields are removed and before localized fields are flattened into the requested locale. Using this Hook will provide you with all locales and all hidden fields via the `doc` argument.
|
||||
|
||||
```ts
|
||||
import { CollectionBeforeReadHook } from 'payload/types';
|
||||
import { CollectionBeforeReadHook } from "payload/types";
|
||||
|
||||
const beforeReadHook: CollectionBeforeReadHook = async ({
|
||||
doc, // full document data
|
||||
@@ -147,7 +150,7 @@ const beforeReadHook: CollectionBeforeReadHook = async ({
|
||||
query, // JSON formatted query
|
||||
}) => {
|
||||
return doc;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### afterRead
|
||||
@@ -155,7 +158,7 @@ const beforeReadHook: CollectionBeforeReadHook = async ({
|
||||
Runs as the last step before documents are returned. Flattens locales, hides protected fields, and removes fields that users do not have access to.
|
||||
|
||||
```ts
|
||||
import { CollectionAfterReadHook } from 'payload/types';
|
||||
import { CollectionAfterReadHook } from "payload/types";
|
||||
|
||||
const afterReadHook: CollectionAfterReadHook = async ({
|
||||
doc, // full document data
|
||||
@@ -164,7 +167,7 @@ const afterReadHook: CollectionAfterReadHook = async ({
|
||||
findMany, // boolean to denote if this hook is running against finding one, or finding many
|
||||
}) => {
|
||||
return doc;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### beforeDelete
|
||||
@@ -194,19 +197,37 @@ const afterDeleteHook: CollectionAfterDeleteHook = async ({
|
||||
}) => {...}
|
||||
```
|
||||
|
||||
### afterOperation
|
||||
|
||||
The `afterOperation` hook can be used to modify the result of operations or execute side-effects that run after an operation has completed.
|
||||
|
||||
Available Collection operations include `create`, `find`, `findByID`, `update`, `updateByID`, `delete`, `deleteByID`, `login`, `refresh`, and `forgotPassword`.
|
||||
|
||||
```ts
|
||||
import { CollectionAfterOperationHook } from "payload/types";
|
||||
|
||||
const afterOperationHook: CollectionAfterOperationHook = async ({
|
||||
args, // arguments passed into the operation
|
||||
operation, // name of the operation
|
||||
result, // the result of the operation, before modifications
|
||||
}) => {
|
||||
return result; // return modified result as necessary
|
||||
};
|
||||
```
|
||||
|
||||
### beforeLogin
|
||||
|
||||
For auth-enabled Collections, this hook runs during `login` operations where a user with the provided credentials exist, but before a token is generated and added to the response. You can optionally modify the user that is returned, or throw an error in order to deny the login operation.
|
||||
|
||||
```ts
|
||||
import { CollectionBeforeLoginHook } from 'payload/types';
|
||||
import { CollectionBeforeLoginHook } from "payload/types";
|
||||
|
||||
const beforeLoginHook: CollectionBeforeLoginHook = async ({
|
||||
req, // full express request
|
||||
user, // user being logged in
|
||||
}) => {
|
||||
return user;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### afterLogin
|
||||
@@ -267,7 +288,7 @@ const afterMeHook: CollectionAfterMeHook = async ({
|
||||
For auth-enabled Collections, this hook runs after successful `forgotPassword` operations. Returned values are discarded.
|
||||
|
||||
```ts
|
||||
import { CollectionAfterForgotPasswordHook } from 'payload/types';
|
||||
import { CollectionAfterForgotPasswordHook } from "payload/types";
|
||||
|
||||
const afterLoginHook: CollectionAfterForgotPasswordHook = async ({
|
||||
req, // full express request
|
||||
@@ -275,7 +296,7 @@ const afterLoginHook: CollectionAfterForgotPasswordHook = async ({
|
||||
token, // user token
|
||||
}) => {
|
||||
return user;
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
@@ -298,5 +319,5 @@ import type {
|
||||
CollectionAfterRefreshHook,
|
||||
CollectionAfterMeHook,
|
||||
CollectionAfterForgotPasswordHook,
|
||||
} from 'payload/types';
|
||||
} from "payload/types";
|
||||
```
|
||||
|
||||
@@ -2,5 +2,5 @@ MONGODB_URI=mongodb://127.0.0.1/payload-example-custom-server
|
||||
PAYLOAD_SECRET=PAYLOAD_CUSTOM_SERVER_EXAMPLE_SECRET_KEY
|
||||
PAYLOAD_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
|
||||
PAYLOAD_SEED=true
|
||||
PAYLOAD_PUBLIC_SEED=true
|
||||
PAYLOAD_DROP_DATABASE=true
|
||||
|
||||
@@ -94,7 +94,7 @@ To spin up this example locally, follow the [Quick Start](#quick-start).
|
||||
|
||||
### Seed
|
||||
|
||||
On boot, a seed script is included to scaffold a basic database for you to use as an example. This is done by setting the `PAYLOAD_DROP_DATABASE` and `PAYLOAD_SEED` environment variables which are included in the `.env.example` by default. You can remove these from your `.env` to prevent this behavior. You can also freshly seed your project at any time by running `yarn seed`. This seed creates an admin user with email `dev@payloadcms.com`, password `test`, and a `home` page.
|
||||
On boot, a seed script is included to scaffold a basic database for you to use as an example. This is done by setting the `PAYLOAD_DROP_DATABASE` and `PAYLOAD_PUBLIC_SEED` environment variables which are included in the `.env.example` by default. You can remove these from your `.env` to prevent this behavior. You can also freshly seed your project at any time by running `yarn seed`. This seed creates an admin user with email `dev@payloadcms.com`, password `test`, and a `home` page.
|
||||
|
||||
> NOTICE: seeding the database is destructive because it drops your current database to populate a fresh one from the seed template. Only run this command if you are starting a new project or can afford to lose your current data.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts nodemon",
|
||||
"seed": "rm -rf media && cross-env PAYLOAD_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts ts-node src/server.ts",
|
||||
"seed": "rm -rf media && cross-env PAYLOAD_PUBLIC_SEED=true PAYLOAD_DROP_DATABASE=true PAYLOAD_CONFIG_PATH=src/payload.config.ts ts-node src/server.ts",
|
||||
"build:payload": "cross-env PAYLOAD_CONFIG_PATH=src/payload.config.ts payload build",
|
||||
"build:server": "tsc --project tsconfig.server.json",
|
||||
"build:next": "cross-env PAYLOAD_CONFIG_PATH=dist/payload.config.js NEXT_BUILD=true node dist/server.js",
|
||||
@@ -52,4 +52,4 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^4.8.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
examples/custom-server/src/app/api/test-get/route.ts
Normal file
5
examples/custom-server/src/app/api/test-get/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
5
examples/custom-server/src/app/api/test-post/route.ts
Normal file
5
examples/custom-server/src/app/api/test-post/route.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
export async function POST(): Promise<NextResponse> {
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import { getPayloadClient } from '../getPayload'
|
||||
import { Page } from './../payload-types'
|
||||
import { Gutter } from './_components/Gutter'
|
||||
import { RichText } from './_components/RichText'
|
||||
@@ -8,11 +9,17 @@ import { RichText } from './_components/RichText'
|
||||
import classes from './page.module.scss'
|
||||
|
||||
export default async function Home() {
|
||||
const home: Page = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_SERVER_URL}/api/pages?where[slug][equals]=home`,
|
||||
)
|
||||
.then(res => res.json())
|
||||
.then(res => res?.docs?.[0])
|
||||
const payload = await getPayloadClient()
|
||||
const { docs } = await payload.find({
|
||||
collection: 'pages',
|
||||
where: {
|
||||
slug: {
|
||||
equals: 'home',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const home = docs?.[0] as Page
|
||||
|
||||
if (!home) {
|
||||
return notFound()
|
||||
|
||||
17
examples/custom-server/src/components/BeforeLogin/index.tsx
Normal file
17
examples/custom-server/src/components/BeforeLogin/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
const BeforeLogin: React.FC = () => {
|
||||
if (process.env.PAYLOAD_PUBLIC_SEED === 'true') {
|
||||
return (
|
||||
<p>
|
||||
{'Log in with the email '}
|
||||
<strong>demo@payloadcms.com</strong>
|
||||
{' and the password '}
|
||||
<strong>demo</strong>.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default BeforeLogin
|
||||
59
examples/custom-server/src/getPayload.ts
Normal file
59
examples/custom-server/src/getPayload.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import dotenv from 'dotenv'
|
||||
import path from 'path'
|
||||
import type { Payload } from 'payload'
|
||||
import payload from 'payload'
|
||||
import type { InitOptions } from 'payload/config'
|
||||
|
||||
import { seed as seedData } from './seed'
|
||||
|
||||
dotenv.config({
|
||||
path: path.resolve(__dirname, '../.env'),
|
||||
})
|
||||
|
||||
let cached = (global as any).payload
|
||||
|
||||
if (!cached) {
|
||||
cached = (global as any).payload = { client: null, promise: null }
|
||||
}
|
||||
|
||||
interface Args {
|
||||
initOptions?: Partial<InitOptions>
|
||||
seed?: boolean
|
||||
}
|
||||
|
||||
export const getPayloadClient = async ({ initOptions, seed }: Args = {}): Promise<Payload> => {
|
||||
if (!process.env.MONGODB_URI) {
|
||||
throw new Error('MONGODB_URI environment variable is missing')
|
||||
}
|
||||
|
||||
if (!process.env.PAYLOAD_SECRET) {
|
||||
throw new Error('PAYLOAD_SECRET environment variable is missing')
|
||||
}
|
||||
|
||||
if (cached.client) {
|
||||
return cached.client
|
||||
}
|
||||
|
||||
if (!cached.promise) {
|
||||
cached.promise = payload.init({
|
||||
mongoURL: process.env.MONGODB_URI,
|
||||
secret: process.env.PAYLOAD_SECRET,
|
||||
local: initOptions?.express ? false : true,
|
||||
...(initOptions || {}),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
process.env.PAYLOAD_DROP_DATABASE = seed ? 'true' : 'false'
|
||||
cached.client = await cached.promise
|
||||
|
||||
if (seed) {
|
||||
await seedData(payload)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
cached.promise = null
|
||||
throw e
|
||||
}
|
||||
|
||||
return cached.client
|
||||
}
|
||||
@@ -8,10 +8,16 @@ dotenv.config({
|
||||
import { buildConfig } from 'payload/config'
|
||||
|
||||
import { Pages } from './collections/Pages'
|
||||
import BeforeLogin from './components/BeforeLogin'
|
||||
|
||||
export default buildConfig({
|
||||
serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL || '',
|
||||
collections: [Pages],
|
||||
admin: {
|
||||
components: {
|
||||
beforeLogin: [BeforeLogin],
|
||||
},
|
||||
},
|
||||
typescript: {
|
||||
outputFile: path.resolve(__dirname, 'payload-types.ts'),
|
||||
},
|
||||
|
||||
@@ -5,8 +5,8 @@ export const seed = async (payload: Payload): Promise<void> => {
|
||||
await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: 'dev@payloadcms.com',
|
||||
password: 'test',
|
||||
email: 'demo@payloadcms.com',
|
||||
password: 'demo',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -10,33 +10,23 @@ dotenv.config({
|
||||
})
|
||||
|
||||
import express from 'express'
|
||||
import payload from 'payload'
|
||||
|
||||
import { seed } from './seed'
|
||||
import { getPayloadClient } from './getPayload'
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3000
|
||||
|
||||
// Redirect root to the admin panel
|
||||
app.get('/', (_, res) => {
|
||||
res.redirect('/admin')
|
||||
})
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
await payload.init({
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
mongoURL: process.env.MONGODB_URI || '',
|
||||
express: app,
|
||||
onInit: () => {
|
||||
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
|
||||
const payload = await getPayloadClient({
|
||||
initOptions: {
|
||||
express: app,
|
||||
onInit: async newPayload => {
|
||||
newPayload.logger.info(`Payload Admin URL: ${newPayload.getAdminURL()}`)
|
||||
},
|
||||
},
|
||||
seed: process.env.PAYLOAD_PUBLIC_SEED === 'true',
|
||||
})
|
||||
|
||||
if (process.env.PAYLOAD_SEED === 'true') {
|
||||
payload.logger.info('---- SEEDING DATABASE ----')
|
||||
await seed(payload)
|
||||
}
|
||||
|
||||
app.listen(PORT, async () => {
|
||||
payload.logger.info(`App URL: ${process.env.PAYLOAD_PUBLIC_SERVER_URL}`)
|
||||
})
|
||||
|
||||
@@ -8,28 +8,23 @@ dotenv.config({
|
||||
})
|
||||
|
||||
import express from 'express'
|
||||
import payload from 'payload'
|
||||
|
||||
import { seed } from './seed'
|
||||
import { getPayloadClient } from './getPayload'
|
||||
|
||||
const app = express()
|
||||
const PORT = process.env.PORT || 3000
|
||||
|
||||
const start = async (): Promise<void> => {
|
||||
await payload.init({
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
mongoURL: process.env.MONGODB_URI || '',
|
||||
express: app,
|
||||
onInit: () => {
|
||||
payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`)
|
||||
const payload = await getPayloadClient({
|
||||
initOptions: {
|
||||
express: app,
|
||||
onInit: async newPayload => {
|
||||
newPayload.logger.info(`Payload Admin URL: ${newPayload.getAdminURL()}`)
|
||||
},
|
||||
},
|
||||
seed: process.env.PAYLOAD_PUBLIC_SEED === 'true',
|
||||
})
|
||||
|
||||
if (process.env.PAYLOAD_SEED === 'true') {
|
||||
payload.logger.info('---- SEEDING DATABASE ----')
|
||||
await seed(payload)
|
||||
}
|
||||
|
||||
if (process.env.NEXT_BUILD) {
|
||||
app.listen(PORT, async () => {
|
||||
payload.logger.info(`Next.js is now building...`)
|
||||
@@ -47,7 +42,7 @@ const start = async (): Promise<void> => {
|
||||
|
||||
const nextHandler = nextApp.getRequestHandler()
|
||||
|
||||
app.get('*', (req, res) => nextHandler(req, res))
|
||||
app.use((req, res) => nextHandler(req, res))
|
||||
|
||||
nextApp.prepare().then(() => {
|
||||
payload.logger.info('Next.js started')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Payload Draft Preview Example Front-End
|
||||
|
||||
This is a [Next.js](https://nextjs.org) app using the [App Router](https://nextjs.org/docs/app). It was made explicitly for Payload's [Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview).
|
||||
This is a [Next.js](https://nextjs.org) app using the [App Router](https://nextjs.org/docs/app). It was made explicitly for Payload's [Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/payload).
|
||||
|
||||
> This example uses the App Router, the latest API of Next.js. If your app is using the legacy [Pages Router](https://nextjs.org/docs/pages), check out the official [Pages Router Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/next-pages).
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ export const fetchPage = async (
|
||||
draft && payloadToken ? '&draft=true' : ''
|
||||
}`,
|
||||
{
|
||||
method: 'GET',
|
||||
// this is the key we'll use to on-demand revalidate pages that use this data
|
||||
// we do this by calling `revalidateTag()` using the same key
|
||||
// see `app/api/revalidate.ts` for more info
|
||||
next: { tags: [`pages_${slug}`] },
|
||||
...(draft && payloadToken
|
||||
? {
|
||||
headers: {
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import { revalidatePath, revalidateTag } from 'next/cache'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
// this endpoint will revalidate a page by tag or path
|
||||
// this is to achieve on-demand revalidation of pages that use this data
|
||||
// send either `collection` and `slug` or `revalidatePath` as query params
|
||||
export async function GET(request: NextRequest): Promise<unknown> {
|
||||
const path = request.nextUrl.searchParams.get('revalidatePath')
|
||||
const collection = request.nextUrl.searchParams.get('collection')
|
||||
const slug = request.nextUrl.searchParams.get('slug')
|
||||
const path = request.nextUrl.searchParams.get('path')
|
||||
const secret = request.nextUrl.searchParams.get('secret')
|
||||
|
||||
if (secret !== process.env.NEXT_PRIVATE_REVALIDATION_KEY) {
|
||||
return NextResponse.json({ revalidated: false, now: Date.now() })
|
||||
}
|
||||
|
||||
if (typeof collection === 'string' && typeof slug === 'string') {
|
||||
revalidateTag(`${collection}_${slug}`)
|
||||
return NextResponse.json({ revalidated: true, now: Date.now() })
|
||||
}
|
||||
|
||||
// there is a known limitation with `revalidatePath` where it will not revalidate exact paths of dynamic routes
|
||||
// instead, Next.js expects us to revalidate entire directories, i.e. `revalidatePath('/[slug]')` instead of `/example-page`
|
||||
// for this reason, it is preferred to use `revalidateTag` instead of `revalidatePath`
|
||||
// - https://github.com/vercel/next.js/issues/49387
|
||||
// - https://github.com/vercel/next.js/issues/49778#issuecomment-1547028830
|
||||
if (typeof path === 'string') {
|
||||
// there is a known bug with `revalidatePath` where it will not revalidate exact paths of dynamic routes
|
||||
// instead, Next.js expects us to revalidate entire directories, i.e. `/[slug]` instead of `/example-page`
|
||||
// for now we'll make this change but with expectation that it will be fixed so we can use `revalidatePath('/example-page')`
|
||||
// - https://github.com/vercel/next.js/issues/49387
|
||||
// - https://github.com/vercel/next.js/issues/49778#issuecomment-1547028830
|
||||
// revalidatePath(path)
|
||||
revalidatePath('/[slug]')
|
||||
revalidatePath(path)
|
||||
return NextResponse.json({ revalidated: true, now: Date.now() })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Payload Draft Preview Example Front-End
|
||||
|
||||
This is a [Next.js](https://nextjs.org) app using the [Pages Router](https://nextjs.org/docs/pages). It was made explicitly for Payload's [Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview).
|
||||
This is a [Next.js](https://nextjs.org) app using the [Pages Router](https://nextjs.org/docs/pages). It was made explicitly for Payload's [Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/payload).
|
||||
|
||||
> This example uses the Pages Router, the legacy API of Next.js. If your app is using the latest [App Router](https://nextjs.org/docs/app), check out the official [App Router Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/next-app).
|
||||
|
||||
|
||||
@@ -6,9 +6,9 @@ const revalidate = async (req: NextApiRequest, res: NextApiResponse): Promise<vo
|
||||
return res.status(401).json({ message: 'Invalid token' })
|
||||
}
|
||||
|
||||
if (typeof req.query.revalidatePath === 'string') {
|
||||
if (typeof req.query.path === 'string') {
|
||||
try {
|
||||
await res.revalidate(req.query.revalidatePath)
|
||||
await res.revalidate(req.query.path)
|
||||
return res.json({ revalidated: true })
|
||||
} catch (err: unknown) {
|
||||
// If there was an error, Next.js will continue
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Payload Draft Preview Example
|
||||
|
||||
The [Payload Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview) demonstrates how to implement draft preview in [Payload](https://github.com/payloadcms/payload) using [Versions](https://payloadcms.com/docs/versions/overview) and [Drafts](https://payloadcms.com/docs/versions/drafts). Draft preview allows you to see content on your front-end before it is published. There are various fully working front-ends made explicitly for this example, including:
|
||||
The [Payload Draft Preview Example](https://github.com/payloadcms/payload/tree/master/examples/draft-preview/payload) demonstrates how to implement draft preview in [Payload](https://github.com/payloadcms/payload) using [Versions](https://payloadcms.com/docs/versions/overview) and [Drafts](https://payloadcms.com/docs/versions/drafts). Draft preview allows you to see content on your front-end before it is published. There are various fully working front-ends made explicitly for this example, including:
|
||||
|
||||
- [Next.js App Router](../next-app)
|
||||
- [Next.js Pages Router](../next-pages)
|
||||
|
||||
@@ -7,9 +7,11 @@ export const formatAppURL = ({ doc }): string => {
|
||||
return pathname
|
||||
}
|
||||
|
||||
// Revalidate the page in the background, so the user doesn't have to wait
|
||||
// Notice that the hook itself is not async and we are not awaiting `revalidate`
|
||||
// Only revalidate existing docs that are published
|
||||
// revalidate the page in the background, so the user doesn't have to wait
|
||||
// notice that the hook itself is not async and we are not awaiting `revalidate`
|
||||
// only revalidate existing docs that are published (not drafts)
|
||||
// send `revalidatePath`, `collection`, and `slug` to the frontend to use in its revalidate route
|
||||
// frameworks may have different ways of doing this, but the idea is the same
|
||||
export const revalidatePage: AfterChangeHook = ({ doc, req, operation }) => {
|
||||
if (operation === 'update' && doc._status === 'published') {
|
||||
const url = formatAppURL({ doc })
|
||||
@@ -17,7 +19,7 @@ export const revalidatePage: AfterChangeHook = ({ doc, req, operation }) => {
|
||||
const revalidate = async (): Promise<void> => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${process.env.PAYLOAD_PUBLIC_SITE_URL}/api/revalidate?secret=${process.env.REVALIDATION_KEY}&revalidatePath=${url}`,
|
||||
`${process.env.PAYLOAD_PUBLIC_SITE_URL}/api/revalidate?secret=${process.env.REVALIDATION_KEY}&collection=pages&slug=${doc?.slug}&path=${url}`,
|
||||
)
|
||||
|
||||
if (res.ok) {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const Pages: CollectionConfig = {
|
||||
formatAppURL({
|
||||
doc,
|
||||
}),
|
||||
)}&secret=${process.env.PAYLOAD_PUBLIC_DRAFT_SECRET}`
|
||||
)}&collection=pages&slug=${doc.slug}&secret=${process.env.PAYLOAD_PUBLIC_DRAFT_SECRET}`
|
||||
},
|
||||
},
|
||||
versions: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Page } from '../payload-types'
|
||||
|
||||
export const examplePageDraft: Partial<Page> = {
|
||||
title: 'Example Page (Draft)',
|
||||
richText: [
|
||||
{
|
||||
children: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload",
|
||||
"version": "1.13.4",
|
||||
"version": "1.15.3",
|
||||
"description": "Node, React and MongoDB Headless CMS and Application Framework",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -46,6 +46,7 @@
|
||||
"test:e2e:headed": "cross-env DISABLE_LOGGING=true playwright test --headed",
|
||||
"test:e2e:debug": "cross-env PWDEBUG=1 DISABLE_LOGGING=true playwright test",
|
||||
"test:components": "cross-env jest --config=jest.components.config.js",
|
||||
"translateNewKeys": "ts-node -T ./scripts/translateNewKeys.ts",
|
||||
"clean:cache": "rimraf node_modules/.cache",
|
||||
"clean": "rimraf dist",
|
||||
"release:patch": "release-it patch",
|
||||
@@ -88,8 +89,8 @@
|
||||
"@faceless-ui/scroll-info": "^1.3.0",
|
||||
"@faceless-ui/window-info": "^2.1.1",
|
||||
"@monaco-editor/react": "^4.5.1",
|
||||
"@swc/core": "^1.3.76",
|
||||
"@swc/register": "^0.1.10",
|
||||
"@swc/core": "1.3.78",
|
||||
"@swc/register": "0.1.10",
|
||||
"@types/sharp": "^0.31.1",
|
||||
"body-parser": "^1.20.1",
|
||||
"bson-objectid": "^2.0.4",
|
||||
|
||||
128
scripts/translateNewKeys.ts
Normal file
128
scripts/translateNewKeys.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
/* eslint-disable no-continue */
|
||||
/* eslint-disable no-restricted-syntax */
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const TRANSLATIONS_DIR = './src/translations';
|
||||
const SOURCE_LANG_FILE = 'en.json';
|
||||
const OPENAI_ENDPOINT = 'https://api.openai.com/v1/chat/completions'; // Adjust if needed
|
||||
const OPENAI_API_KEY = 'sk-YOURKEYHERE'; // Remember to replace with your actual key
|
||||
|
||||
|
||||
async function main() {
|
||||
const sourceLangContent = JSON.parse(fs.readFileSync(path.join(TRANSLATIONS_DIR, SOURCE_LANG_FILE), 'utf8'));
|
||||
|
||||
const files = fs.readdirSync(TRANSLATIONS_DIR);
|
||||
|
||||
for (const file of files) {
|
||||
if (file === SOURCE_LANG_FILE) {
|
||||
continue;
|
||||
}
|
||||
// check if file ends with .json
|
||||
if (!file.endsWith('.json')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// skip the translation-schema.json file
|
||||
if (file === 'translation-schema.json') {
|
||||
continue;
|
||||
}
|
||||
console.log('Processing file:', file);
|
||||
|
||||
const targetLangContent = JSON.parse(fs.readFileSync(path.join(TRANSLATIONS_DIR, file), 'utf8'));
|
||||
const missingKeys = findMissingKeys(sourceLangContent, targetLangContent);
|
||||
|
||||
let hasChanged = false;
|
||||
|
||||
for (const missingKey of missingKeys) {
|
||||
const keys = missingKey.split('.');
|
||||
const sourceText = keys.reduce((acc, key) => acc[key], sourceLangContent);
|
||||
const targetLang = file.split('.')[0];
|
||||
|
||||
const translatedText = await translateText(sourceText, targetLang);
|
||||
let targetObj = targetLangContent;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i += 1) {
|
||||
if (!targetObj[keys[i]]) {
|
||||
targetObj[keys[i]] = {};
|
||||
}
|
||||
targetObj = targetObj[keys[i]];
|
||||
}
|
||||
|
||||
targetObj[keys[keys.length - 1]] = translatedText;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
|
||||
if (hasChanged) {
|
||||
const sortedContent = sortKeys(targetLangContent);
|
||||
fs.writeFileSync(path.join(TRANSLATIONS_DIR, file), JSON.stringify(sortedContent, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().then(() => {
|
||||
console.log('Translation update completed.');
|
||||
}).catch((error) => {
|
||||
console.error('Error occurred:', error);
|
||||
});
|
||||
|
||||
async function translateText(text: string, targetLang: string): Promise<string> {
|
||||
const response = await fetch(OPENAI_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
max_tokens: 150,
|
||||
model: 'gpt-4',
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `Only respond with the translation of the text you receive. The original language is English and the translation language is ${targetLang}. Only respond with the translation - do not say anything else. If you cannot translate the text, respond with "[SKIPPED]"`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: text,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log(' Old text:', text, 'New text:', data.choices[0].message.content.trim());
|
||||
return data.choices[0].message.content.trim();
|
||||
}
|
||||
|
||||
function findMissingKeys(baseObj: any, targetObj: any, prefix = ''): string[] {
|
||||
let missingKeys = [];
|
||||
|
||||
for (const key in baseObj) {
|
||||
if (typeof baseObj[key] === 'object') {
|
||||
missingKeys = missingKeys.concat(findMissingKeys(baseObj[key], targetObj[key] || {}, `${prefix}${key}.`));
|
||||
} else if (!(key in targetObj)) {
|
||||
missingKeys.push(`${prefix}${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
return missingKeys;
|
||||
}
|
||||
|
||||
function sortKeys(obj: any): any {
|
||||
if (typeof obj !== 'object' || obj === null) return obj;
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(sortKeys);
|
||||
}
|
||||
|
||||
const sortedKeys = Object.keys(obj).sort();
|
||||
const sortedObj: { [key: string]: any } = {};
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
sortedObj[key] = sortKeys(obj[key]);
|
||||
}
|
||||
|
||||
return sortedObj;
|
||||
}
|
||||
@@ -72,6 +72,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, Props>((props,
|
||||
iconPosition = 'right',
|
||||
newTab,
|
||||
tooltip,
|
||||
'aria-label': ariaLabel,
|
||||
} = props;
|
||||
|
||||
const [showTooltip, setShowTooltip] = React.useState(false);
|
||||
@@ -101,6 +102,8 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, Props>((props,
|
||||
type,
|
||||
className: classes,
|
||||
disabled,
|
||||
'aria-disabled': disabled,
|
||||
'aria-label': ariaLabel,
|
||||
onMouseEnter: tooltip ? () => setShowTooltip(true) : undefined,
|
||||
onMouseLeave: tooltip ? () => setShowTooltip(false) : undefined,
|
||||
onClick: !disabled ? handleClick : undefined,
|
||||
|
||||
@@ -19,4 +19,5 @@ export type Props = {
|
||||
iconPosition?: 'left' | 'right',
|
||||
newTab?: boolean
|
||||
tooltip?: string
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
padding: base(1.25) $baseline;
|
||||
position: relative;
|
||||
|
||||
h5 {
|
||||
&__title {
|
||||
@extend %h5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -7,7 +7,7 @@ import './index.scss';
|
||||
const baseClass = 'card';
|
||||
|
||||
const Card: React.FC<Props> = (props) => {
|
||||
const { id, title, actions, onClick } = props;
|
||||
const { id, title, titleAs, buttonAriaLabel, actions, onClick } = props;
|
||||
|
||||
const classes = [
|
||||
baseClass,
|
||||
@@ -15,14 +15,16 @@ const Card: React.FC<Props> = (props) => {
|
||||
onClick && `${baseClass}--has-onclick`,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const Tag = titleAs ?? 'div';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
id={id}
|
||||
>
|
||||
<h5>
|
||||
<Tag className={`${baseClass}__title`}>
|
||||
{title}
|
||||
</h5>
|
||||
</Tag>
|
||||
{actions && (
|
||||
<div className={`${baseClass}__actions`}>
|
||||
{actions}
|
||||
@@ -30,6 +32,7 @@ const Card: React.FC<Props> = (props) => {
|
||||
)}
|
||||
{onClick && (
|
||||
<Button
|
||||
aria-label={buttonAriaLabel}
|
||||
className={`${baseClass}__click`}
|
||||
buttonStyle="none"
|
||||
onClick={onClick}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { ElementType } from 'react';
|
||||
|
||||
export type Props = {
|
||||
id?: string,
|
||||
title: string,
|
||||
titleAs?: ElementType,
|
||||
buttonAriaLabel?: string,
|
||||
actions?: React.ReactNode,
|
||||
onClick?: () => void,
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import React from 'react';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { Props } from './types';
|
||||
import { useTheme } from '../../utilities/Theme';
|
||||
import { ShimmerEffect } from '../ShimmerEffect';
|
||||
|
||||
import './index.scss';
|
||||
import { ShimmerEffect } from '../ShimmerEffect';
|
||||
|
||||
const baseClass = 'code-editor';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useId, useState } from 'react';
|
||||
import React, { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Pill from '../Pill';
|
||||
import Plus from '../../icons/Plus';
|
||||
@@ -61,6 +61,7 @@ const ColumnSelector: React.FC<Props> = (props) => {
|
||||
alignIcon="left"
|
||||
key={`${collection.slug}-${col.name || i}${editDepth ? `-${editDepth}-` : ''}${uuid}`}
|
||||
icon={active ? <X /> : <Plus />}
|
||||
aria-checked={active}
|
||||
className={[
|
||||
`${baseClass}__column`,
|
||||
active && `${baseClass}__column--active`,
|
||||
|
||||
@@ -5,11 +5,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Props, TogglerProps } from './types';
|
||||
import { EditDepthContext, useEditDepth } from '../../utilities/EditDepth';
|
||||
import { Gutter } from '../Gutter';
|
||||
import './index.scss';
|
||||
import X from '../../icons/X';
|
||||
|
||||
const baseClass = 'drawer';
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'drawer';
|
||||
const zBase = 100;
|
||||
|
||||
export const formatDrawerSlug = ({
|
||||
|
||||
@@ -145,6 +145,7 @@ const EditMany: React.FC<Props> = (props) => {
|
||||
<Form
|
||||
className={`${baseClass}__form`}
|
||||
onSuccess={onSuccess}
|
||||
configFieldsSchema={selected}
|
||||
>
|
||||
<div className={`${baseClass}__main`}>
|
||||
<div className={`${baseClass}__header`}>
|
||||
|
||||
@@ -38,6 +38,11 @@ const getUseAsTitle = (collection: SanitizedCollectionConfig) => {
|
||||
return topLevelFields.find((field) => fieldAffectsData(field) && field.name === useAsTitle);
|
||||
};
|
||||
|
||||
/**
|
||||
* The ListControls component is used to render the controls (search, filter, where)
|
||||
* for a collection's list view. You can find those directly above the table which lists
|
||||
* the collection's documents.
|
||||
*/
|
||||
const ListControls: React.FC<Props> = (props) => {
|
||||
const {
|
||||
collection,
|
||||
@@ -105,6 +110,8 @@ const ListControls: React.FC<Props> = (props) => {
|
||||
pillStyle="light"
|
||||
className={`${baseClass}__toggle-columns ${visibleDrawer === 'columns' ? `${baseClass}__buttons-active` : ''}`}
|
||||
onClick={() => setVisibleDrawer(visibleDrawer !== 'columns' ? 'columns' : undefined)}
|
||||
aria-expanded={visibleDrawer === 'columns'}
|
||||
aria-controls={`${baseClass}-columns`}
|
||||
icon={<Chevron />}
|
||||
>
|
||||
{t('columns')}
|
||||
@@ -114,6 +121,8 @@ const ListControls: React.FC<Props> = (props) => {
|
||||
pillStyle="light"
|
||||
className={`${baseClass}__toggle-where ${visibleDrawer === 'where' ? `${baseClass}__buttons-active` : ''}`}
|
||||
onClick={() => setVisibleDrawer(visibleDrawer !== 'where' ? 'where' : undefined)}
|
||||
aria-expanded={visibleDrawer === 'where'}
|
||||
aria-controls={`${baseClass}-where`}
|
||||
icon={<Chevron />}
|
||||
>
|
||||
{t('filters')}
|
||||
@@ -123,6 +132,8 @@ const ListControls: React.FC<Props> = (props) => {
|
||||
className={`${baseClass}__toggle-sort`}
|
||||
buttonStyle={visibleDrawer === 'sort' ? undefined : 'secondary'}
|
||||
onClick={() => setVisibleDrawer(visibleDrawer !== 'sort' ? 'sort' : undefined)}
|
||||
aria-expanded={visibleDrawer === 'sort'}
|
||||
aria-controls={`${baseClass}-sort`}
|
||||
icon="chevron"
|
||||
iconStyle="none"
|
||||
>
|
||||
@@ -136,6 +147,7 @@ const ListControls: React.FC<Props> = (props) => {
|
||||
<AnimateHeight
|
||||
className={`${baseClass}__columns`}
|
||||
height={visibleDrawer === 'columns' ? 'auto' : 0}
|
||||
id={`${baseClass}-columns`}
|
||||
>
|
||||
<ColumnSelector collection={collection} />
|
||||
</AnimateHeight>
|
||||
@@ -143,6 +155,7 @@ const ListControls: React.FC<Props> = (props) => {
|
||||
<AnimateHeight
|
||||
className={`${baseClass}__where`}
|
||||
height={visibleDrawer === 'where' ? 'auto' : 0}
|
||||
id={`${baseClass}-where`}
|
||||
>
|
||||
<WhereBuilder
|
||||
collection={collection}
|
||||
@@ -154,6 +167,7 @@ const ListControls: React.FC<Props> = (props) => {
|
||||
<AnimateHeight
|
||||
className={`${baseClass}__sort`}
|
||||
height={visibleDrawer === 'sort' ? 'auto' : 0}
|
||||
id={`${baseClass}-sort`}
|
||||
>
|
||||
<SortComplex
|
||||
modifySearchQuery={modifySearchQuery}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useConfig } from '../../utilities/Config';
|
||||
import RenderCustomComponent from '../../utilities/RenderCustomComponent';
|
||||
import LogOut from '../../icons/LogOut';
|
||||
@@ -7,16 +8,21 @@ import LogOut from '../../icons/LogOut';
|
||||
const baseClass = 'nav';
|
||||
|
||||
const DefaultLogout = () => {
|
||||
const { t } = useTranslation('authentication');
|
||||
const config = useConfig();
|
||||
const {
|
||||
routes: { admin },
|
||||
admin: {
|
||||
logoutRoute,
|
||||
components: { logout }
|
||||
}
|
||||
components: { logout },
|
||||
},
|
||||
} = config;
|
||||
return (
|
||||
<Link to={`${admin}${logoutRoute}`} className={`${baseClass}__log-out`}>
|
||||
<Link
|
||||
to={`${admin}${logoutRoute}`}
|
||||
className={`${baseClass}__log-out`}
|
||||
aria-label={t('logOut')}
|
||||
>
|
||||
<LogOut />
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ const DefaultNav = () => {
|
||||
const [menuActive, setMenuActive] = useState(false);
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const history = useHistory();
|
||||
const { i18n } = useTranslation('general');
|
||||
const { t, i18n } = useTranslation('general');
|
||||
const {
|
||||
collections,
|
||||
globals,
|
||||
@@ -81,6 +81,7 @@ const DefaultNav = () => {
|
||||
<Link
|
||||
to={admin}
|
||||
className={`${baseClass}__brand`}
|
||||
aria-label={t('dashboard')}
|
||||
>
|
||||
<Icon />
|
||||
</Link>
|
||||
@@ -141,6 +142,7 @@ const DefaultNav = () => {
|
||||
<Link
|
||||
to={`${admin}/account`}
|
||||
className={`${baseClass}__account`}
|
||||
aria-label={t('authentication:account')}
|
||||
>
|
||||
<Account />
|
||||
</Link>
|
||||
|
||||
@@ -45,6 +45,10 @@ const StaticPill: React.FC<Props> = (props) => {
|
||||
children,
|
||||
elementProps,
|
||||
rounded,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-expanded': ariaExpanded,
|
||||
'aria-controls': ariaControls,
|
||||
'aria-checked': ariaChecked,
|
||||
} = props;
|
||||
|
||||
const classes = [
|
||||
@@ -67,6 +71,10 @@ const StaticPill: React.FC<Props> = (props) => {
|
||||
return (
|
||||
<Element
|
||||
{...elementProps}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={ariaExpanded}
|
||||
aria-controls={ariaControls}
|
||||
aria-checked={ariaChecked}
|
||||
className={classes}
|
||||
type={Element === 'button' ? 'button' : undefined}
|
||||
to={to || undefined}
|
||||
|
||||
@@ -11,6 +11,10 @@ export type Props = {
|
||||
draggable?: boolean,
|
||||
rounded?: boolean
|
||||
id?: string
|
||||
'aria-label'?: string,
|
||||
'aria-expanded'?: boolean,
|
||||
'aria-controls'?: string,
|
||||
'aria-checked'?: boolean,
|
||||
elementProps?: HTMLAttributes<HTMLElement> & {
|
||||
ref: React.RefCallback<HTMLElement>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { components as SelectComponents, MultiValueProps } from 'react-select';
|
||||
import type { Option } from '../types';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'multi-value-label';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { MultiValueRemoveProps } from 'react-select';
|
||||
import X from '../../../icons/X';
|
||||
import Tooltip from '../../Tooltip';
|
||||
import { Option as OptionType } from '../types';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'multi-value-remove';
|
||||
|
||||
@@ -45,6 +45,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
|
||||
components,
|
||||
isCreatable,
|
||||
selectProps,
|
||||
noOptionsMessage,
|
||||
} = props;
|
||||
|
||||
const classes = [
|
||||
@@ -72,6 +73,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
|
||||
filterOption={filterOption}
|
||||
onMenuOpen={onMenuOpen}
|
||||
menuPlacement="auto"
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
components={{
|
||||
ValueContainer,
|
||||
SingleValue,
|
||||
@@ -134,6 +136,7 @@ const SelectAdapter: React.FC<ReactSelectAdapterProps> = (props) => {
|
||||
inputValue={inputValue}
|
||||
onInputChange={(newValue) => setInputValue(newValue)}
|
||||
onKeyDown={handleKeyDown}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
components={{
|
||||
ValueContainer,
|
||||
SingleValue,
|
||||
|
||||
@@ -43,6 +43,7 @@ export type OptionGroup = {
|
||||
}
|
||||
|
||||
export type Props = {
|
||||
inputId?: string
|
||||
className?: string
|
||||
value?: Option | Option[],
|
||||
onChange?: (value: any) => void, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
@@ -76,4 +77,5 @@ export type Props = {
|
||||
*/
|
||||
selectProps?: CustomSelectProps
|
||||
backspaceRemovesValue?: boolean
|
||||
noOptionsMessage?: (obj: { inputValue: string }) => string
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const SortColumn: React.FC<Props> = (props) => {
|
||||
} = props;
|
||||
const params = useSearchParams();
|
||||
const history = useHistory();
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation('general');
|
||||
|
||||
const { sort } = params;
|
||||
|
||||
@@ -50,6 +50,7 @@ const SortColumn: React.FC<Props> = (props) => {
|
||||
buttonStyle="none"
|
||||
className={ascClasses.join(' ')}
|
||||
onClick={() => setSort(asc)}
|
||||
aria-label={t('sortByLabelDirection', { label: getTranslation(label, i18n), direction: t('ascending') })}
|
||||
>
|
||||
<Chevron />
|
||||
</Button>
|
||||
@@ -58,6 +59,7 @@ const SortColumn: React.FC<Props> = (props) => {
|
||||
buttonStyle="none"
|
||||
className={descClasses.join(' ')}
|
||||
onClick={() => setSort(desc)}
|
||||
aria-label={t('sortByLabelDirection', { label: getTranslation(label, i18n), direction: t('descending') })}
|
||||
>
|
||||
<Chevron />
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Props, isComponent } from './types';
|
||||
import { getTranslation } from '../../../../utilities/getTranslation';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const ViewDescription: React.FC<Props> = (props) => {
|
||||
|
||||
@@ -57,6 +57,16 @@ const geo = [
|
||||
},
|
||||
];
|
||||
|
||||
const within = {
|
||||
label: 'within',
|
||||
value: 'within',
|
||||
};
|
||||
|
||||
const intersects = {
|
||||
label: 'intersects',
|
||||
value: 'intersects',
|
||||
};
|
||||
|
||||
const like = {
|
||||
label: 'isLike',
|
||||
value: 'like',
|
||||
@@ -86,7 +96,7 @@ const fieldTypeConditions = {
|
||||
},
|
||||
json: {
|
||||
component: 'Text',
|
||||
operators: [...base, like, contains],
|
||||
operators: [...base, like, contains, within, intersects],
|
||||
},
|
||||
richText: {
|
||||
component: 'Text',
|
||||
@@ -102,7 +112,7 @@ const fieldTypeConditions = {
|
||||
},
|
||||
point: {
|
||||
component: 'Point',
|
||||
operators: [...geo],
|
||||
operators: [...geo, within, intersects],
|
||||
},
|
||||
upload: {
|
||||
component: 'Text',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useSearchParams } from '../../utilities/SearchParams';
|
||||
import validateWhereQuery from './validateWhereQuery';
|
||||
import { Where } from '../../../../types';
|
||||
import { getTranslation } from '../../../../utilities/getTranslation';
|
||||
import { transformWhereQuery } from './transformWhereQuery';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -43,6 +44,10 @@ const reduceFields = (fields, i18n) => flattenTopLevelFields(fields).reduce((red
|
||||
return reduced;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* The WhereBuilder component is used to render the filter controls for a collection's list view.
|
||||
* It is part of the {@link ListControls} component which is used to render the controls (search, filter, where).
|
||||
*/
|
||||
const WhereBuilder: React.FC<Props> = (props) => {
|
||||
const {
|
||||
collection,
|
||||
@@ -59,16 +64,30 @@ const WhereBuilder: React.FC<Props> = (props) => {
|
||||
const params = useSearchParams();
|
||||
const { t, i18n } = useTranslation('general');
|
||||
|
||||
// This handles initializing the where conditions from the search query (URL). That way, if you pass in
|
||||
// query params to the URL, the where conditions will be initialized from those and displayed in the UI.
|
||||
// Example: /admin/collections/posts?where[or][0][and][0][text][equals]=example%20post
|
||||
const [conditions, dispatchConditions] = useReducer(reducer, params.where, (whereFromSearch) => {
|
||||
if (modifySearchQuery && validateWhereQuery(whereFromSearch)) {
|
||||
return whereFromSearch.or;
|
||||
}
|
||||
if (modifySearchQuery && whereFromSearch) {
|
||||
if (validateWhereQuery(whereFromSearch)) {
|
||||
return whereFromSearch.or;
|
||||
}
|
||||
|
||||
// Transform the where query to be in the right format. This will transform something simple like [text][equals]=example%20post to the right format
|
||||
const transformedWhere = transformWhereQuery(whereFromSearch);
|
||||
|
||||
if (validateWhereQuery(transformedWhere)) {
|
||||
return transformedWhere.or;
|
||||
}
|
||||
|
||||
console.warn('Invalid where query in URL. Ignoring.');
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [reducedFields] = useState(() => reduceFields(collection.fields, i18n));
|
||||
|
||||
// This handles updating the search query (URL) when the where conditions change
|
||||
useThrottledEffect(() => {
|
||||
const currentParams = queryString.parse(history.location.search, { ignoreQueryPrefix: true, depth: 10 }) as { where: Where };
|
||||
|
||||
@@ -83,8 +102,11 @@ const WhereBuilder: React.FC<Props> = (props) => {
|
||||
];
|
||||
}, []) : [];
|
||||
|
||||
const hasNewWhereConditions = conditions.length > 0;
|
||||
|
||||
|
||||
const newWhereQuery = {
|
||||
...typeof currentParams?.where === 'object' ? currentParams.where : {},
|
||||
...typeof currentParams?.where === 'object' && (validateWhereQuery(currentParams?.where) || !hasNewWhereConditions) ? currentParams.where : {},
|
||||
or: [
|
||||
...conditions,
|
||||
...paramsToKeep,
|
||||
@@ -94,7 +116,6 @@ const WhereBuilder: React.FC<Props> = (props) => {
|
||||
if (handleChange) handleChange(newWhereQuery as Where);
|
||||
|
||||
const hasExistingConditions = typeof currentParams?.where === 'object' && 'or' in currentParams.where;
|
||||
const hasNewWhereConditions = conditions.length > 0;
|
||||
|
||||
if (modifySearchQuery && ((hasExistingConditions && !hasNewWhereConditions) || hasNewWhereConditions)) {
|
||||
history.replace({
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Where } from '../../../../types';
|
||||
|
||||
/**
|
||||
* Something like [or][0][and][0][text][equals]=example%20post will work and pass through the validateWhereQuery check.
|
||||
* However, something like [text][equals]=example%20post will not work and will fail the validateWhereQuery check,
|
||||
* even though it is a valid Where query. This needs to be transformed here.
|
||||
*/
|
||||
export const transformWhereQuery = (whereQuery): Where => {
|
||||
if (!whereQuery) {
|
||||
return {};
|
||||
}
|
||||
// Check if 'whereQuery' has 'or' field but no 'and'. This is the case for "correct" queries
|
||||
if (whereQuery.or && !whereQuery.and) {
|
||||
return {
|
||||
or: whereQuery.or.map((query) => {
|
||||
// ...but if the or query does not have an and, we need to add it
|
||||
if(!query.and) {
|
||||
return {
|
||||
and: [query]
|
||||
}
|
||||
}
|
||||
return query;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Check if 'whereQuery' has 'and' field but no 'or'.
|
||||
if (whereQuery.and && !whereQuery.or) {
|
||||
return {
|
||||
or: [
|
||||
{
|
||||
and: whereQuery.and,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if 'whereQuery' has neither 'or' nor 'and'.
|
||||
if (!whereQuery.or && !whereQuery.and) {
|
||||
return {
|
||||
or: [
|
||||
{
|
||||
and: [whereQuery], // top-level siblings are considered 'and'
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// If 'whereQuery' has 'or' and 'and', just return it as it is.
|
||||
return whereQuery;
|
||||
};
|
||||
@@ -1,8 +1,37 @@
|
||||
import { Where } from '../../../../types';
|
||||
import type { Operator, Where } from '../../../../types';
|
||||
import { validOperators } from '../../../../types/constants';
|
||||
|
||||
const validateWhereQuery = (whereQuery): whereQuery is Where => {
|
||||
if (whereQuery?.or?.length > 0 && whereQuery?.or?.[0]?.and && whereQuery?.or?.[0]?.and?.length > 0) {
|
||||
return true;
|
||||
// At this point we know that the whereQuery has 'or' and 'and' fields,
|
||||
// now let's check the structure and content of these fields.
|
||||
|
||||
const isValid = whereQuery.or.every((orQuery) => {
|
||||
if (orQuery.and && Array.isArray(orQuery.and)) {
|
||||
return orQuery.and.every((andQuery) => {
|
||||
if (typeof andQuery !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const andKeys = Object.keys(andQuery);
|
||||
// If there are no keys, it's not a valid WhereField.
|
||||
if (andKeys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const key of andKeys) {
|
||||
const operator = Object.keys(andQuery[key])[0];
|
||||
// Check if the key is a valid Operator.
|
||||
if (!operator || !validOperators.includes(operator as Operator)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -111,7 +111,7 @@ export const addFieldStatePromise = async ({
|
||||
|
||||
acc.rowMetadata.push({
|
||||
id: row.id,
|
||||
collapsed: collapsedRowIDs === undefined ? field.admin.initCollapsed : collapsedRowIDs.includes(row.id),
|
||||
collapsed: collapsedRowIDs === undefined ? Boolean(field?.admin?.initCollapsed) : collapsedRowIDs.includes(row.id),
|
||||
childErrorPaths: new Set(),
|
||||
});
|
||||
|
||||
@@ -191,7 +191,7 @@ export const addFieldStatePromise = async ({
|
||||
|
||||
acc.rowMetadata.push({
|
||||
id: row.id,
|
||||
collapsed: collapsedRowIDs === undefined ? field.admin.initCollapsed : collapsedRowIDs.includes(row.id),
|
||||
collapsed: collapsedRowIDs === undefined ? Boolean(field?.admin?.initCollapsed) : collapsedRowIDs.includes(row.id),
|
||||
blockType: row.blockType,
|
||||
childErrorPaths: new Set(),
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ const Form: React.FC<Props> = (props) => {
|
||||
initialState, // fully formed initial field state
|
||||
initialData, // values only, paths are required as key - form should build initial state as convenience
|
||||
waitForAutocomplete,
|
||||
configFieldsSchema,
|
||||
} = props;
|
||||
|
||||
const history = useHistory();
|
||||
@@ -409,12 +410,14 @@ const Form: React.FC<Props> = (props) => {
|
||||
path: string,
|
||||
blockType?: string
|
||||
}) => {
|
||||
const rowConfig = traverseRowConfigs({ path, fieldConfig: collection?.fields || global?.fields });
|
||||
if (!configFieldsSchema) return null;
|
||||
|
||||
const rowConfig = traverseRowConfigs({ path, fieldConfig: configFieldsSchema });
|
||||
const rowFieldConfigs = buildFieldSchemaMap(rowConfig);
|
||||
const pathSegments = splitPathByArrayFields(path);
|
||||
const fieldKey = pathSegments.at(-1);
|
||||
return rowFieldConfigs.get(blockType ? `${fieldKey}.${blockType}` : fieldKey);
|
||||
}, [traverseRowConfigs, collection?.fields, global?.fields]);
|
||||
}, [traverseRowConfigs, configFieldsSchema]);
|
||||
|
||||
// Array/Block row manipulation
|
||||
const addFieldRow: Context['addFieldRow'] = useCallback(async ({ path, rowIndex, data }) => {
|
||||
|
||||
@@ -49,6 +49,7 @@ export type Props = {
|
||||
validationOperation?: 'create' | 'update'
|
||||
children?: React.ReactNode
|
||||
action?: string
|
||||
configFieldsSchema?: FieldConfig[]
|
||||
}
|
||||
|
||||
export type SubmitOptions = {
|
||||
|
||||
@@ -1,62 +1,74 @@
|
||||
import React from 'react';
|
||||
import Check from '../../../icons/Check';
|
||||
import Label from '../../Label';
|
||||
import Line from '../../../icons/Line';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'custom-checkbox';
|
||||
|
||||
type CheckboxInputProps = {
|
||||
onToggle: React.MouseEventHandler<HTMLButtonElement>
|
||||
onToggle: React.FormEventHandler<HTMLInputElement>
|
||||
inputRef?: React.MutableRefObject<HTMLInputElement>
|
||||
readOnly?: boolean
|
||||
checked?: boolean
|
||||
partialChecked?: boolean
|
||||
name?: string
|
||||
id?: string
|
||||
label?: string
|
||||
'aria-label'?: string
|
||||
required?: boolean
|
||||
}
|
||||
|
||||
export const CheckboxInput: React.FC<CheckboxInputProps> = (props) => {
|
||||
const {
|
||||
onToggle,
|
||||
checked,
|
||||
partialChecked,
|
||||
inputRef,
|
||||
name,
|
||||
id,
|
||||
label,
|
||||
'aria-label': ariaLabel,
|
||||
readOnly,
|
||||
required,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
<div
|
||||
className={[
|
||||
baseClass,
|
||||
checked && `${baseClass}--checked`,
|
||||
(checked || partialChecked) && `${baseClass}--checked`,
|
||||
readOnly && `${baseClass}--read-only`,
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
checked={checked}
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className={`${baseClass}__input`}>
|
||||
<Check />
|
||||
<div className={`${baseClass}__input`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
type="checkbox"
|
||||
name={name}
|
||||
aria-label={ariaLabel}
|
||||
checked={Boolean(checked)}
|
||||
disabled={readOnly}
|
||||
onInput={onToggle}
|
||||
/>
|
||||
<span className={`${baseClass}__icon ${!partialChecked ? 'check' : 'partial'}`}>
|
||||
{!partialChecked && (
|
||||
<Check />
|
||||
)}
|
||||
{partialChecked && (
|
||||
<Line />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{label && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
label={label}
|
||||
required={required}
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
position: relative;
|
||||
margin-bottom: $baseline;
|
||||
|
||||
input[type=checkbox] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tooltip:not([aria-hidden="true"]) {
|
||||
right: auto;
|
||||
position: relative;
|
||||
@@ -22,32 +18,84 @@
|
||||
|
||||
|
||||
.custom-checkbox {
|
||||
display: inline-flex;
|
||||
|
||||
label {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
// hidden HTML checkbox
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
padding-left: base(.5);
|
||||
}
|
||||
|
||||
&__input {
|
||||
// visible checkbox
|
||||
@include formInput;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
width: $baseline;
|
||||
height: $baseline;
|
||||
margin-right: base(.5);
|
||||
|
||||
& input[type="checkbox"] {
|
||||
position: absolute;
|
||||
// Without the extra 4px, there is an uncheckable area due to the border of the parent element
|
||||
width: calc(100% + 4px);
|
||||
height: calc(100% + 4px);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: -2px;
|
||||
margin-top: -2px;
|
||||
opacity: 0;
|
||||
border-radius: 0;
|
||||
z-index: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
position: absolute;
|
||||
|
||||
svg {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
&:not(&--read-only) {
|
||||
&:active,
|
||||
&:focus-within,
|
||||
&:focus {
|
||||
.custom-checkbox__input, & input[type="checkbox"] {
|
||||
@include inputShadowActive;
|
||||
|
||||
outline: 0;
|
||||
box-shadow: 0 0 3px 3px var(--theme-success-400)!important;
|
||||
border: 1px solid var(--theme-elevation-150);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.custom-checkbox__input, & input[type="checkbox"] {
|
||||
border-color: var(--theme-elevation-250);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not(&--read-only):not(&--checked) {
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--checked {
|
||||
.custom-checkbox__icon {
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--read-only {
|
||||
.custom-checkbox__input {
|
||||
@@ -58,40 +106,6 @@
|
||||
color: var(--theme-elevation-400);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
@extend %btn-reset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
.custom-checkbox__input {
|
||||
box-shadow: 0 0 3px 3px var(--theme-success-400);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
opacity: .2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--checked {
|
||||
button {
|
||||
.custom-checkbox__input {
|
||||
svg {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme=light] {
|
||||
|
||||
@@ -88,6 +88,7 @@ const Checkbox: React.FC<Props> = (props) => {
|
||||
label={getTranslation(label || name, i18n)}
|
||||
name={path}
|
||||
checked={Boolean(value)}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
<FieldDescription
|
||||
value={value}
|
||||
|
||||
@@ -10,9 +10,9 @@ import { Props } from './types';
|
||||
import { getTranslation } from '../../../../../utilities/getTranslation';
|
||||
import { Option } from '../../../elements/ReactSelect/types';
|
||||
import ReactSelect from '../../../elements/ReactSelect';
|
||||
import { isNumber } from '../../../../../utilities/isNumber';
|
||||
|
||||
import './index.scss';
|
||||
import { isNumber } from '../../../../../utilities/isNumber';
|
||||
|
||||
const NumberField: React.FC<Props> = (props) => {
|
||||
const {
|
||||
@@ -143,9 +143,17 @@ const NumberField: React.FC<Props> = (props) => {
|
||||
isMulti
|
||||
isSortable
|
||||
isClearable
|
||||
noOptionsMessage={({ inputValue }) => {
|
||||
const isOverHasMany = Array.isArray(value) && value.length >= maxRows;
|
||||
if (isOverHasMany) {
|
||||
return t('validation:limitReached', { value: value.length + 1, max: maxRows });
|
||||
}
|
||||
return t('general:noOptions');
|
||||
}}
|
||||
filterOption={(option, rawInput) => {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
return isNumber(rawInput)
|
||||
const isOverHasMany = Array.isArray(value) && value.length >= maxRows;
|
||||
return isNumber(rawInput) && !isOverHasMany;
|
||||
}}
|
||||
numberOnly
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import Tooltip from '../../../../../elements/Tooltip';
|
||||
import Edit from '../../../../../icons/Edit';
|
||||
import { useAuth } from '../../../../../utilities/Auth';
|
||||
import { Option } from '../../types';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'relationship--multi-value-label';
|
||||
|
||||
@@ -6,11 +6,11 @@ import FormSubmit from '../../../../../Submit';
|
||||
import { Props } from './types';
|
||||
import fieldTypes from '../../../..';
|
||||
import RenderFields from '../../../../../RenderFields';
|
||||
|
||||
import './index.scss';
|
||||
import useHotkey from '../../../../../../../hooks/useHotkey';
|
||||
import { useEditDepth } from '../../../../../../utilities/EditDepth';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'rich-text-link-edit-modal';
|
||||
|
||||
export const LinkDrawer: React.FC<Props> = ({
|
||||
@@ -30,6 +30,7 @@ export const LinkDrawer: React.FC<Props> = ({
|
||||
<Form
|
||||
onSubmit={handleModalSubmit}
|
||||
initialState={initialState}
|
||||
configFieldsSchema={fieldSchema}
|
||||
>
|
||||
<RenderFields
|
||||
fieldTypes={fieldTypes}
|
||||
|
||||
@@ -71,6 +71,7 @@ export const UploadDrawer: React.FC<ElementProps & {
|
||||
<Form
|
||||
onSubmit={handleUpdateEditData}
|
||||
initialState={initialState}
|
||||
configFieldsSchema={fieldSchema}
|
||||
>
|
||||
<RenderFields
|
||||
readOnly={false}
|
||||
|
||||
@@ -88,6 +88,7 @@ const DefaultAccount: React.FC<Props> = (props) => {
|
||||
onSuccess={onSave}
|
||||
initialState={initialState}
|
||||
disabled={!hasSavePermission}
|
||||
configFieldsSchema={fields}
|
||||
>
|
||||
<div className={`${baseClass}__main`}>
|
||||
<Meta
|
||||
@@ -130,9 +131,11 @@ const DefaultAccount: React.FC<Props> = (props) => {
|
||||
<h3>{t('general:payloadSettings')}</h3>
|
||||
<div className={`${baseClass}__language`}>
|
||||
<Label
|
||||
htmlFor="language-select"
|
||||
label={t('general:language')}
|
||||
/>
|
||||
<ReactSelect
|
||||
inputId="language-select"
|
||||
value={languageOptions.find((language) => (language.value === i18n.language))}
|
||||
options={languageOptions}
|
||||
onChange={({ value }) => (i18n.changeLanguage(value))}
|
||||
|
||||
@@ -52,6 +52,11 @@ const CreateFirstUser: React.FC<Props> = (props) => {
|
||||
},
|
||||
] as Field[];
|
||||
|
||||
const fieldSchema = [
|
||||
...fields,
|
||||
...userConfig.fields,
|
||||
];
|
||||
|
||||
return (
|
||||
<MinimalTemplate className={baseClass}>
|
||||
<h1>{t('general:welcome')}</h1>
|
||||
@@ -67,12 +72,10 @@ const CreateFirstUser: React.FC<Props> = (props) => {
|
||||
redirect={admin}
|
||||
action={`${serverURL}${api}/${userSlug}/first-register`}
|
||||
validationOperation="create"
|
||||
configFieldsSchema={fieldSchema}
|
||||
>
|
||||
<RenderFields
|
||||
fieldSchema={[
|
||||
...fields,
|
||||
...userConfig.fields,
|
||||
]}
|
||||
fieldSchema={fieldSchema}
|
||||
fieldTypes={fieldTypes}
|
||||
/>
|
||||
<FormSubmit>{t('general:create')}</FormSubmit>
|
||||
|
||||
@@ -24,7 +24,7 @@ const Dashboard: React.FC<Props> = (props) => {
|
||||
} = props;
|
||||
|
||||
const { push } = useHistory();
|
||||
const { i18n } = useTranslation('general');
|
||||
const { t, i18n } = useTranslation('general');
|
||||
|
||||
const {
|
||||
routes: {
|
||||
@@ -77,12 +77,14 @@ const Dashboard: React.FC<Props> = (props) => {
|
||||
<ul className={`${baseClass}__card-list`}>
|
||||
{entities.map(({ entity, type }, entityIndex) => {
|
||||
let title: string;
|
||||
let buttonAriaLabel: string;
|
||||
let createHREF: string;
|
||||
let onClick: () => void;
|
||||
let hasCreatePermission: boolean;
|
||||
|
||||
if (type === EntityType.collection) {
|
||||
title = getTranslation(entity.labels.plural, i18n);
|
||||
buttonAriaLabel = t('showAllLabel', { label: title });
|
||||
onClick = () => push({ pathname: `${admin}/collections/${entity.slug}` });
|
||||
createHREF = `${admin}/collections/${entity.slug}/create`;
|
||||
hasCreatePermission = permissions?.collections?.[entity.slug]?.create?.permission;
|
||||
@@ -90,6 +92,7 @@ const Dashboard: React.FC<Props> = (props) => {
|
||||
|
||||
if (type === EntityType.global) {
|
||||
title = getTranslation(entity.label, i18n);
|
||||
buttonAriaLabel = t('editLabel', { label: getTranslation(entity.label, i18n) });
|
||||
onClick = () => push({ pathname: `${admin}/globals/${entity.slug}` });
|
||||
}
|
||||
|
||||
@@ -97,8 +100,10 @@ const Dashboard: React.FC<Props> = (props) => {
|
||||
<li key={entityIndex}>
|
||||
<Card
|
||||
title={title}
|
||||
titleAs="h3"
|
||||
id={`card-${entity.slug}`}
|
||||
onClick={onClick}
|
||||
buttonAriaLabel={buttonAriaLabel}
|
||||
actions={(hasCreatePermission && type === EntityType.collection) ? (
|
||||
<Button
|
||||
el="link"
|
||||
@@ -107,6 +112,7 @@ const Dashboard: React.FC<Props> = (props) => {
|
||||
round
|
||||
buttonStyle="icon-label"
|
||||
iconStyle="with-border"
|
||||
aria-label={t('createNewLabel', { label: getTranslation(entity.labels.singular, i18n) })}
|
||||
/>
|
||||
) : undefined}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,6 @@ import FormSubmit from '../../forms/Submit';
|
||||
import Button from '../../elements/Button';
|
||||
import Meta from '../../utilities/Meta';
|
||||
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'forgot-password';
|
||||
|
||||
@@ -60,6 +60,7 @@ const DefaultGlobalView: React.FC<Props> = (props) => {
|
||||
onSuccess={onSave}
|
||||
disabled={!hasSavePermission}
|
||||
initialState={initialState}
|
||||
configFieldsSchema={fields}
|
||||
>
|
||||
<FormLoadingOverlayToggle
|
||||
action="update"
|
||||
|
||||
@@ -13,7 +13,6 @@ import Button from '../../elements/Button';
|
||||
import Meta from '../../utilities/Meta';
|
||||
import HiddenInput from '../../forms/field-types/HiddenInput';
|
||||
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'reset-password';
|
||||
|
||||
@@ -106,6 +106,7 @@ const DefaultEditView: React.FC<Props> = (props) => {
|
||||
onSuccess={onSave}
|
||||
disabled={!hasSavePermission}
|
||||
initialState={internalState}
|
||||
configFieldsSchema={fields}
|
||||
>
|
||||
<FormLoadingOverlayToggle
|
||||
formIsLoading={isLoading}
|
||||
|
||||
@@ -100,7 +100,10 @@ const DefaultList: React.FC<Props> = (props) => {
|
||||
{getTranslation(pluralLabel, i18n)}
|
||||
</h1>
|
||||
{hasCreatePermission && (
|
||||
<Pill to={newDocumentURL}>
|
||||
<Pill
|
||||
to={newDocumentURL}
|
||||
aria-label={t('createNewLabel', { label: getTranslation(singularLabel, i18n) })}
|
||||
>
|
||||
{t('createNew')}
|
||||
</Pill>
|
||||
)}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
@import '../../../../../scss/styles.scss';
|
||||
|
||||
.select-all {
|
||||
button {
|
||||
@extend %btn-reset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus:not(:focus-visible),
|
||||
&:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
opacity: .2;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline-offset: var(--accessibility-outline-offset);
|
||||
}
|
||||
}
|
||||
|
||||
&__input {
|
||||
@include formInput;
|
||||
padding: 0;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
width: $baseline;
|
||||
height: $baseline;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,22 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SelectAllStatus, useSelection } from '../SelectionProvider';
|
||||
import Check from '../../../../icons/Check';
|
||||
import Line from '../../../../icons/Line';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'select-all';
|
||||
import { CheckboxInput } from '../../../../forms/field-types/Checkbox/Input';
|
||||
|
||||
const SelectAll: React.FC = () => {
|
||||
const { t } = useTranslation('general');
|
||||
const { selectAll, toggleAll } = useSelection();
|
||||
|
||||
return (
|
||||
<div className={baseClass}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAll()}
|
||||
>
|
||||
<span className={`${baseClass}__input`}>
|
||||
{ (selectAll === SelectAllStatus.AllInPage || selectAll === SelectAllStatus.AllAvailable) && (
|
||||
<Check />
|
||||
)}
|
||||
{ selectAll === SelectAllStatus.Some && (
|
||||
<Line />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<CheckboxInput
|
||||
id="select-all"
|
||||
aria-label={selectAll === SelectAllStatus.None ? t('selectAllRows') : t('deselectAllRows')}
|
||||
checked={selectAll === SelectAllStatus.AllInPage || selectAll === SelectAllStatus.AllAvailable}
|
||||
partialChecked={selectAll === SelectAllStatus.Some}
|
||||
onToggle={() => toggleAll()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,31 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useSelection } from '../SelectionProvider';
|
||||
import Check from '../../../../icons/Check';
|
||||
import { CheckboxInput } from '../../../../forms/field-types/Checkbox/Input';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'select-row';
|
||||
|
||||
const SelectRow: React.FC<{ id: string | number }> = ({ id }) => {
|
||||
const { selected, setSelection } = useSelection();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
baseClass,
|
||||
(selected[id]) && `${baseClass}--checked`,
|
||||
].filter(Boolean).join(' ')}
|
||||
key={id}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelection(id)}
|
||||
>
|
||||
<span className={`${baseClass}__input`}>
|
||||
<Check />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<CheckboxInput
|
||||
checked={selected[id]}
|
||||
onToggle={() => setSelection(id)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,12 @@ import { useSearchParams } from '../../../utilities/SearchParams';
|
||||
import { TableColumnsProvider } from '../../../elements/TableColumns';
|
||||
import type { Field } from '../../../../../fields/config/types';
|
||||
|
||||
/**
|
||||
* The ListView component is table which lists the collection's documents.
|
||||
* The default list view can be found at the {@link DefaultList} component.
|
||||
* Users can also create pass their own custom list view component instead
|
||||
* of using the default one.
|
||||
*/
|
||||
const ListView: React.FC<ListIndexProps> = (props) => {
|
||||
const {
|
||||
collection,
|
||||
|
||||
@@ -177,8 +177,9 @@ html[data-theme=dark] {
|
||||
--theme-elevation-250: var(--color-base-650);
|
||||
--theme-elevation-300: var(--color-base-600);
|
||||
--theme-elevation-350: var(--color-base-550);
|
||||
--theme-elevation-400: var(--color-base-450);
|
||||
--theme-elevation-450: var(--color-base-400);
|
||||
--theme-elevation-400: var(--color-base-500);
|
||||
--theme-elevation-450: var(--color-base-450);
|
||||
--theme-elevation-500: var(--color-base-400);
|
||||
--theme-elevation-550: var(--color-base-350);
|
||||
--theme-elevation-600: var(--color-base-300);
|
||||
--theme-elevation-650: var(--color-base-250);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Document } from 'mongoose';
|
||||
import { APIError } from '../../errors';
|
||||
import { PayloadRequest } from '../../express/types';
|
||||
import { Collection } from '../../collections/config/types';
|
||||
import { buildAfterOperation } from '../../collections/operations/utils';
|
||||
|
||||
export type Arguments = {
|
||||
collection: Collection
|
||||
@@ -128,6 +129,16 @@ async function forgotPassword(incomingArgs: Arguments): Promise<string | null> {
|
||||
await hook({ args, context: req.context });
|
||||
}, Promise.resolve());
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterOperation - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
token = await buildAfterOperation({
|
||||
operation: 'forgotPassword',
|
||||
args,
|
||||
result: token,
|
||||
});
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { User } from '../types';
|
||||
import { Collection } from '../../collections/config/types';
|
||||
import { afterRead } from '../../fields/hooks/afterRead';
|
||||
import unlock from './unlock';
|
||||
import { buildAfterOperation } from '../../collections/operations/utils';
|
||||
import { incrementLoginAttempts } from '../strategies/local/incrementLoginAttempts';
|
||||
import { authenticateLocalStrategy } from '../strategies/local/authenticate';
|
||||
import { getFieldsToSign } from './getFieldsToSign';
|
||||
@@ -206,15 +207,28 @@ async function login<TSlug extends keyof GeneratedTypes['collections']>(
|
||||
}) || user;
|
||||
}, Promise.resolve());
|
||||
|
||||
// /////////////////////////////////////
|
||||
// Return results
|
||||
// /////////////////////////////////////
|
||||
|
||||
return {
|
||||
let result: Result & { user: GeneratedTypes['collections'][TSlug] } = {
|
||||
token,
|
||||
user,
|
||||
exp: (jwt.decode(token) as jwt.JwtPayload).exp,
|
||||
};
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterOperation - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
result = await buildAfterOperation<GeneratedTypes['collections'][TSlug]>({
|
||||
operation: 'login',
|
||||
args,
|
||||
result,
|
||||
});
|
||||
|
||||
// /////////////////////////////////////
|
||||
// Return results
|
||||
// /////////////////////////////////////
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default login;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import url from 'url';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Response } from 'express';
|
||||
import url from 'url';
|
||||
import { Collection, BeforeOperationHook } from '../../collections/config/types';
|
||||
import { Forbidden } from '../../errors';
|
||||
import getCookieExpiration from '../../utilities/getCookieExpiration';
|
||||
import { Document } from '../../types';
|
||||
import { PayloadRequest } from '../../express/types';
|
||||
import { buildAfterOperation } from '../../collections/operations/utils';
|
||||
import { getFieldsToSign } from './getFieldsToSign';
|
||||
|
||||
export type Result = {
|
||||
@@ -97,7 +98,7 @@ async function refresh(incomingArgs: Arguments): Promise<Result> {
|
||||
args.res.cookie(`${config.cookiePrefix}-token`, refreshedToken, cookieOptions);
|
||||
}
|
||||
|
||||
let response: Result = {
|
||||
let result: Result = {
|
||||
user,
|
||||
refreshedToken,
|
||||
exp,
|
||||
@@ -110,20 +111,31 @@ async function refresh(incomingArgs: Arguments): Promise<Result> {
|
||||
await collectionConfig.hooks.afterRefresh.reduce(async (priorHook, hook) => {
|
||||
await priorHook;
|
||||
|
||||
response = (await hook({
|
||||
result = (await hook({
|
||||
req: args.req,
|
||||
res: args.res,
|
||||
exp,
|
||||
token: refreshedToken,
|
||||
context: args.req.context,
|
||||
})) || response;
|
||||
})) || result;
|
||||
}, Promise.resolve());
|
||||
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterOperation - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
result = await buildAfterOperation({
|
||||
operation: 'refresh',
|
||||
args,
|
||||
result,
|
||||
});
|
||||
|
||||
// /////////////////////////////////////
|
||||
// Return results
|
||||
// /////////////////////////////////////
|
||||
|
||||
return response;
|
||||
return result;
|
||||
}
|
||||
|
||||
export default refresh;
|
||||
|
||||
@@ -16,7 +16,7 @@ const swcOptions = {
|
||||
tsx: true,
|
||||
},
|
||||
paths: undefined,
|
||||
baseUrl: __dirname,
|
||||
baseUrl: path.resolve(),
|
||||
},
|
||||
module: {
|
||||
type: 'commonjs',
|
||||
|
||||
@@ -29,6 +29,7 @@ export const defaults = {
|
||||
afterRead: [],
|
||||
beforeDelete: [],
|
||||
afterDelete: [],
|
||||
afterOperation: [],
|
||||
beforeLogin: [],
|
||||
afterLogin: [],
|
||||
afterLogout: [],
|
||||
|
||||
@@ -98,6 +98,7 @@ const collectionSchema = joi.object().keys({
|
||||
afterRead: joi.array().items(joi.func()),
|
||||
beforeDelete: joi.array().items(joi.func()),
|
||||
afterDelete: joi.array().items(joi.func()),
|
||||
afterOperation: joi.array().items(joi.func()),
|
||||
beforeLogin: joi.array().items(joi.func()),
|
||||
afterLogin: joi.array().items(joi.func()),
|
||||
afterLogout: joi.array().items(joi.func()),
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IncomingUploadType, Upload } from '../../uploads/types';
|
||||
import { IncomingCollectionVersions, SanitizedCollectionVersions } from '../../versions/types';
|
||||
import { BuildQueryArgs } from '../../mongoose/buildQuery';
|
||||
import { CustomPreviewButtonProps, CustomPublishButtonProps, CustomSaveButtonProps, CustomSaveDraftButtonProps } from '../../admin/components/elements/types';
|
||||
import { AfterOperationArg, AfterOperationMap } from '../operations/utils';
|
||||
import type { Props as ListProps } from '../../admin/components/views/collections/List/types';
|
||||
import type { Props as EditProps } from '../../admin/components/views/collections/Edit/types';
|
||||
|
||||
@@ -123,6 +124,13 @@ export type AfterDeleteHook<T extends TypeWithID = any> = (args: {
|
||||
context: RequestContext;
|
||||
}) => any;
|
||||
|
||||
|
||||
export type AfterOperationHook<
|
||||
T extends TypeWithID = any,
|
||||
> = (
|
||||
arg: AfterOperationArg<T>,
|
||||
) => Promise<ReturnType<AfterOperationMap<T>[keyof AfterOperationMap<T>]>>;
|
||||
|
||||
export type AfterErrorHook = (err: Error, res: unknown, context: RequestContext) => { response: any, status: number } | void;
|
||||
|
||||
export type BeforeLoginHook<T extends TypeWithID = any> = (args: {
|
||||
@@ -314,6 +322,7 @@ export type CollectionConfig = {
|
||||
afterMe?: AfterMeHook[];
|
||||
afterRefresh?: AfterRefreshHook[];
|
||||
afterForgotPassword?: AfterForgotPasswordHook[];
|
||||
afterOperation?: AfterOperationHook[];
|
||||
};
|
||||
/**
|
||||
* Custom rest api endpoints
|
||||
|
||||
@@ -22,11 +22,14 @@ import { afterRead } from '../../fields/hooks/afterRead';
|
||||
import { generateFileData } from '../../uploads/generateFileData';
|
||||
import { saveVersion } from '../../versions/saveVersion';
|
||||
import { mapAsync } from '../../utilities/mapAsync';
|
||||
import { buildAfterOperation } from './utils';
|
||||
import { registerLocalStrategy } from '../../auth/strategies/local/register';
|
||||
|
||||
const unlinkFile = promisify(fs.unlink);
|
||||
|
||||
export type Arguments<T extends { [field: string | number | symbol]: unknown }> = {
|
||||
export type CreateUpdateType = { [field: string | number | symbol]: unknown }
|
||||
|
||||
export type Arguments<T extends CreateUpdateType> = {
|
||||
collection: Collection
|
||||
req: PayloadRequest
|
||||
depth?: number
|
||||
@@ -318,6 +321,16 @@ async function create<TSlug extends keyof GeneratedTypes['collections']>(
|
||||
}) || result;
|
||||
}, Promise.resolve());
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterOperation - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
result = await buildAfterOperation<GeneratedTypes['collections'][TSlug]>({
|
||||
operation: 'create',
|
||||
args,
|
||||
result,
|
||||
});
|
||||
|
||||
// Remove temp files if enabled, as express-fileupload does not do this automatically
|
||||
if (config.upload?.useTempFiles && collectionConfig.upload) {
|
||||
const { files } = req;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Where } from '../../types';
|
||||
import { afterRead } from '../../fields/hooks/afterRead';
|
||||
import { deleteCollectionVersions } from '../../versions/deleteCollectionVersions';
|
||||
import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles';
|
||||
import { buildAfterOperation } from './utils';
|
||||
|
||||
export type Arguments = {
|
||||
depth?: number
|
||||
@@ -212,10 +213,22 @@ async function deleteOperation<TSlug extends keyof GeneratedTypes['collections']
|
||||
}
|
||||
preferences.Model.deleteMany({ key: { in: docs.map(({ id }) => `collection-${collectionConfig.slug}-${id}`) } });
|
||||
|
||||
return {
|
||||
let result = {
|
||||
docs: awaitedDocs.filter(Boolean),
|
||||
errors,
|
||||
};
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterOperation - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
result = await buildAfterOperation<GeneratedTypes['collections'][TSlug]>({
|
||||
operation: 'delete',
|
||||
args,
|
||||
result,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default deleteOperation;
|
||||
|
||||
@@ -4,11 +4,12 @@ import sanitizeInternalFields from '../../utilities/sanitizeInternalFields';
|
||||
import { NotFound, Forbidden } from '../../errors';
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import { BeforeOperationHook, Collection } from '../config/types';
|
||||
import { Document, Where } from '../../types';
|
||||
import { Document } from '../../types';
|
||||
import { hasWhereAccessResult } from '../../auth/types';
|
||||
import { afterRead } from '../../fields/hooks/afterRead';
|
||||
import { deleteCollectionVersions } from '../../versions/deleteCollectionVersions';
|
||||
import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles';
|
||||
import { buildAfterOperation } from './utils';
|
||||
|
||||
export type Arguments = {
|
||||
depth?: number
|
||||
@@ -174,6 +175,16 @@ async function deleteByID<TSlug extends keyof GeneratedTypes['collections']>(inc
|
||||
result = await hook({ req, id, doc: result, context: req.context }) || result;
|
||||
}, Promise.resolve());
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterOperation - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
result = await buildAfterOperation<GeneratedTypes['collections'][TSlug]>({
|
||||
operation: 'deleteByID',
|
||||
args,
|
||||
result,
|
||||
});
|
||||
|
||||
// /////////////////////////////////////
|
||||
// 8. Return results
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -9,6 +9,7 @@ import { buildSortParam } from '../../mongoose/buildSortParam';
|
||||
import { AccessResult } from '../../config/types';
|
||||
import { afterRead } from '../../fields/hooks/afterRead';
|
||||
import { queryDrafts } from '../../versions/drafts/queryDrafts';
|
||||
import { buildAfterOperation } from './utils';
|
||||
|
||||
export type Arguments = {
|
||||
collection: Collection
|
||||
@@ -226,6 +227,16 @@ async function find<T extends TypeWithID & Record<string, unknown>>(
|
||||
})),
|
||||
};
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterOperation - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
result = await buildAfterOperation<T>({
|
||||
operation: 'find',
|
||||
args,
|
||||
result,
|
||||
});
|
||||
|
||||
// /////////////////////////////////////
|
||||
// Return results
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -7,6 +7,7 @@ import { NotFound } from '../../errors';
|
||||
import executeAccess from '../../auth/executeAccess';
|
||||
import replaceWithDraftIfAvailable from '../../versions/drafts/replaceWithDraftIfAvailable';
|
||||
import { afterRead } from '../../fields/hooks/afterRead';
|
||||
import { buildAfterOperation } from './utils';
|
||||
|
||||
export type Arguments = {
|
||||
collection: Collection
|
||||
@@ -173,6 +174,16 @@ async function findByID<T extends TypeWithID>(
|
||||
}) || result;
|
||||
}, Promise.resolve());
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterOperation - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
result = await buildAfterOperation<T>({
|
||||
operation: 'findByID',
|
||||
args,
|
||||
result,
|
||||
});
|
||||
|
||||
// /////////////////////////////////////
|
||||
// Return results
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -124,12 +124,28 @@ async function restoreVersion<T extends TypeWithID = any>(args: Arguments): Prom
|
||||
|
||||
delete prevVersion.id;
|
||||
|
||||
await VersionModel.updateMany({
|
||||
$and: [
|
||||
{
|
||||
parent: {
|
||||
$eq: parentDocID,
|
||||
},
|
||||
},
|
||||
{
|
||||
latest: {
|
||||
$eq: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}, { $unset: { latest: 1 } });
|
||||
|
||||
await VersionModel.create({
|
||||
parent: parentDocID,
|
||||
version: rawVersion.version,
|
||||
autosave: false,
|
||||
createdAt: prevVersion.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
latest: payload.config.database.queryDrafts_2_0 ? true : undefined,
|
||||
});
|
||||
|
||||
// /////////////////////////////////////
|
||||
|
||||
@@ -18,8 +18,10 @@ import { AccessResult } from '../../config/types';
|
||||
import { queryDrafts } from '../../versions/drafts/queryDrafts';
|
||||
import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles';
|
||||
import { unlinkTempFiles } from '../../uploads/unlinkTempFiles';
|
||||
import { buildAfterOperation } from './utils';
|
||||
import { CreateUpdateType } from './create';
|
||||
|
||||
export type Arguments<T extends { [field: string | number | symbol]: unknown }> = {
|
||||
export type Arguments<T extends CreateUpdateType> = {
|
||||
collection: Collection
|
||||
req: PayloadRequest
|
||||
where: Where
|
||||
@@ -350,10 +352,22 @@ async function update<TSlug extends keyof GeneratedTypes['collections']>(
|
||||
|
||||
const awaitedDocs = await Promise.all(promises);
|
||||
|
||||
return {
|
||||
let result = {
|
||||
docs: awaitedDocs.filter(Boolean),
|
||||
errors,
|
||||
};
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterOperation - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
result = await buildAfterOperation<GeneratedTypes['collections'][TSlug]>({
|
||||
operation: 'update',
|
||||
args,
|
||||
result,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default update;
|
||||
|
||||
@@ -18,6 +18,7 @@ import { generateFileData } from '../../uploads/generateFileData';
|
||||
import { getLatestCollectionVersion } from '../../versions/getLatestCollectionVersion';
|
||||
import { deleteAssociatedFiles } from '../../uploads/deleteAssociatedFiles';
|
||||
import { unlinkTempFiles } from '../../uploads/unlinkTempFiles';
|
||||
import { buildAfterOperation } from './utils';
|
||||
import { generatePasswordSaltHash } from '../../auth/strategies/local/generatePasswordSaltHash';
|
||||
|
||||
export type Arguments<T extends { [field: string | number | symbol]: unknown }> = {
|
||||
@@ -340,6 +341,16 @@ async function updateByID<TSlug extends keyof GeneratedTypes['collections']>(
|
||||
}) || result;
|
||||
}, Promise.resolve());
|
||||
|
||||
// /////////////////////////////////////
|
||||
// afterOperation - Collection
|
||||
// /////////////////////////////////////
|
||||
|
||||
result = await buildAfterOperation<GeneratedTypes['collections'][TSlug]>({
|
||||
operation: 'updateByID',
|
||||
args,
|
||||
result,
|
||||
});
|
||||
|
||||
await unlinkTempFiles({
|
||||
req,
|
||||
config,
|
||||
|
||||
71
src/collections/operations/utils.ts
Normal file
71
src/collections/operations/utils.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import find from './find';
|
||||
import update from './update';
|
||||
import deleteOperation from './delete';
|
||||
import create from './create';
|
||||
import login from '../../auth/operations/login';
|
||||
import refresh from '../../auth/operations/refresh';
|
||||
import findByID from './findByID';
|
||||
import updateByID from './updateByID';
|
||||
import deleteByID from './deleteByID';
|
||||
import { AfterOperationHook, TypeWithID } from '../config/types';
|
||||
import forgotPassword from '../../auth/operations/forgotPassword';
|
||||
|
||||
export type AfterOperationMap<
|
||||
T extends TypeWithID,
|
||||
> = {
|
||||
create: typeof create, // todo: pass correct generic
|
||||
find: typeof find<T>,
|
||||
findByID: typeof findByID<T>,
|
||||
update: typeof update, // todo: pass correct generic
|
||||
updateByID: typeof updateByID, // todo: pass correct generic
|
||||
delete: typeof deleteOperation, // todo: pass correct generic
|
||||
deleteByID: typeof deleteByID, // todo: pass correct generic
|
||||
login: typeof login,
|
||||
refresh: typeof refresh,
|
||||
forgotPassword: typeof forgotPassword,
|
||||
}
|
||||
export type AfterOperationArg<T extends TypeWithID> =
|
||||
| { operation: 'create'; result: Awaited<ReturnType<AfterOperationMap<T>['create']>>, args: Parameters<AfterOperationMap<T>['create']>[0] }
|
||||
| { operation: 'find'; result: Awaited<ReturnType<AfterOperationMap<T>['find']>>, args: Parameters<AfterOperationMap<T>['find']>[0] }
|
||||
| { operation: 'findByID'; result: Awaited<ReturnType<AfterOperationMap<T>['findByID']>>, args: Parameters<AfterOperationMap<T>['findByID']>[0] }
|
||||
| { operation: 'update'; result: Awaited<ReturnType<AfterOperationMap<T>['update']>>, args: Parameters<AfterOperationMap<T>['update']>[0] }
|
||||
| { operation: 'updateByID'; result: Awaited<ReturnType<AfterOperationMap<T>['updateByID']>>, args: Parameters<AfterOperationMap<T>['updateByID']>[0] }
|
||||
| { operation: 'delete'; result: Awaited<ReturnType<AfterOperationMap<T>['delete']>>, args: Parameters<AfterOperationMap<T>['delete']>[0] }
|
||||
| { operation: 'deleteByID'; result: Awaited<ReturnType<AfterOperationMap<T>['deleteByID']>>, args: Parameters<AfterOperationMap<T>['deleteByID']>[0] }
|
||||
| { operation: 'login'; result: Awaited<ReturnType<AfterOperationMap<T>['login']>>, args: Parameters<AfterOperationMap<T>['login']>[0] }
|
||||
| { operation: 'refresh'; result: Awaited<ReturnType<AfterOperationMap<T>['refresh']>>, args: Parameters<AfterOperationMap<T>['refresh']>[0] }
|
||||
| { operation: 'forgotPassword'; result: Awaited<ReturnType<AfterOperationMap<T>['forgotPassword']>>, args: Parameters<AfterOperationMap<T>['forgotPassword']>[0] };
|
||||
|
||||
// export type AfterOperationHook = typeof buildAfterOperation;
|
||||
|
||||
export const buildAfterOperation = async <
|
||||
T extends TypeWithID = any,
|
||||
O extends keyof AfterOperationMap<T> = keyof AfterOperationMap<T>
|
||||
>
|
||||
(
|
||||
operationArgs: AfterOperationArg<T> & { operation: O },
|
||||
): Promise<Awaited<ReturnType<AfterOperationMap<T>[O]>>> => {
|
||||
const {
|
||||
operation,
|
||||
args,
|
||||
result,
|
||||
} = operationArgs;
|
||||
|
||||
let newResult = result;
|
||||
|
||||
await args.collection.config.hooks.afterOperation.reduce(async (priorHook, hook: AfterOperationHook<T>) => {
|
||||
await priorHook;
|
||||
|
||||
const hookResult = await hook({
|
||||
operation,
|
||||
args,
|
||||
result: newResult,
|
||||
} as AfterOperationArg<T>);
|
||||
|
||||
if (hookResult !== undefined) {
|
||||
newResult = hookResult;
|
||||
}
|
||||
}, Promise.resolve());
|
||||
|
||||
return newResult;
|
||||
};
|
||||
@@ -56,4 +56,7 @@ export const defaults: Config = {
|
||||
localization: false,
|
||||
telemetry: true,
|
||||
custom: {},
|
||||
database: {
|
||||
queryDrafts_2_0: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,54 +9,53 @@ import checkDuplicateCollections from '../utilities/checkDuplicateCollections';
|
||||
import { defaults } from './defaults';
|
||||
import getDefaultBundler from '../bundlers/webpack/bundler';
|
||||
|
||||
const sanitizeAdmin = (config: SanitizedConfig): SanitizedConfig['admin'] => {
|
||||
const adminConfig = config.admin;
|
||||
const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig> => {
|
||||
const sanitizedConfig = { ...configToSanitize };
|
||||
|
||||
// add default user collection if none provided
|
||||
if (!adminConfig?.user) {
|
||||
const firstCollectionWithAuth = config.collections.find(({ auth }) => Boolean(auth));
|
||||
if (!sanitizedConfig?.admin?.user) {
|
||||
const firstCollectionWithAuth = sanitizedConfig.collections.find(({ auth }) => Boolean(auth));
|
||||
if (firstCollectionWithAuth) {
|
||||
adminConfig.user = firstCollectionWithAuth.slug;
|
||||
sanitizedConfig.admin.user = firstCollectionWithAuth.slug;
|
||||
} else {
|
||||
adminConfig.user = 'users';
|
||||
const sanitizedDefaultUser = sanitizeCollection(config, defaultUserCollection);
|
||||
config.collections.push(sanitizedDefaultUser);
|
||||
sanitizedConfig.admin.user = defaultUserCollection.slug;
|
||||
sanitizedConfig.collections.push(defaultUserCollection);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.collections.find(({ slug }) => slug === adminConfig.user)) {
|
||||
throw new InvalidConfiguration(`${config.admin.user} is not a valid admin user collection`);
|
||||
if (!sanitizedConfig.collections.find(({ slug }) => slug === sanitizedConfig.admin.user)) {
|
||||
throw new InvalidConfiguration(`${sanitizedConfig.admin.user} is not a valid admin user collection`);
|
||||
}
|
||||
|
||||
// add default bundler if none provided
|
||||
if (!adminConfig.bundler) {
|
||||
adminConfig.bundler = getDefaultBundler();
|
||||
if (!sanitizedConfig.admin.bundler) {
|
||||
sanitizedConfig.admin.bundler = getDefaultBundler();
|
||||
}
|
||||
|
||||
return adminConfig;
|
||||
return sanitizedConfig as Partial<SanitizedConfig>;
|
||||
};
|
||||
|
||||
export const sanitizeConfig = (config: Config): SanitizedConfig => {
|
||||
const sanitizedConfig: Config = merge(defaults, config, {
|
||||
export const sanitizeConfig = (incomingConfig: Config): SanitizedConfig => {
|
||||
const configWithDefaults: Config = merge(defaults, incomingConfig, {
|
||||
isMergeableObject: isPlainObject,
|
||||
}) as Config;
|
||||
});
|
||||
|
||||
sanitizedConfig.admin = sanitizeAdmin(sanitizedConfig as SanitizedConfig);
|
||||
const config: Partial<SanitizedConfig> = sanitizeAdminConfig(configWithDefaults);
|
||||
config.collections = config.collections.map((collection) => sanitizeCollection(configWithDefaults, collection));
|
||||
|
||||
sanitizedConfig.collections = sanitizedConfig.collections.map((collection) => sanitizeCollection(sanitizedConfig, collection));
|
||||
checkDuplicateCollections(sanitizedConfig.collections);
|
||||
checkDuplicateCollections(config.collections);
|
||||
|
||||
if (sanitizedConfig.globals.length > 0) {
|
||||
sanitizedConfig.globals = sanitizeGlobals(sanitizedConfig.collections, sanitizedConfig.globals);
|
||||
if (config.globals.length > 0) {
|
||||
config.globals = sanitizeGlobals(config.collections, config.globals);
|
||||
}
|
||||
|
||||
if (typeof sanitizedConfig.serverURL === 'undefined') {
|
||||
sanitizedConfig.serverURL = '';
|
||||
if (typeof config.serverURL === 'undefined') {
|
||||
config.serverURL = '';
|
||||
}
|
||||
|
||||
if (sanitizedConfig.serverURL !== '') {
|
||||
sanitizedConfig.csrf.push(sanitizedConfig.serverURL);
|
||||
if (config.serverURL !== '') {
|
||||
config.csrf.push(config.serverURL);
|
||||
}
|
||||
|
||||
return sanitizedConfig as SanitizedConfig;
|
||||
return config as SanitizedConfig;
|
||||
};
|
||||
|
||||
@@ -178,4 +178,7 @@ export default joi.object({
|
||||
onInit: joi.func(),
|
||||
debug: joi.boolean(),
|
||||
custom: joi.object().pattern(joi.string(), joi.any()),
|
||||
database: joi.object().keys({
|
||||
queryDrafts_2_0: joi.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -555,6 +555,15 @@ export type Config = {
|
||||
onInit?: (payload: Payload) => Promise<void> | void;
|
||||
/** Extension point to add your custom data. */
|
||||
custom?: Record<string, any>;
|
||||
/** database specific configurations */
|
||||
database?: {
|
||||
/**
|
||||
* Enable the v2.0 drafts query improvement, before 2.0.
|
||||
*
|
||||
* You must run the migration script for this feature to function properly.
|
||||
*/
|
||||
queryDrafts_2_0?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type SanitizedConfig = Omit<
|
||||
|
||||
@@ -3,10 +3,8 @@ import merge from 'deepmerge';
|
||||
import { Field, fieldAffectsData, TabAsField, tabHasName } from '../../config/types';
|
||||
import { Operation } from '../../../types';
|
||||
import { PayloadRequest, RequestContext } from '../../../express/types';
|
||||
import getValueWithDefault from '../../getDefaultValue';
|
||||
import { traverseFields } from './traverseFields';
|
||||
import { getExistingRowDoc } from './getExistingRowDoc';
|
||||
import { cloneDataFromOriginalDoc } from './cloneDataFromOriginalDoc';
|
||||
|
||||
type Args = {
|
||||
data: Record<string, unknown>
|
||||
@@ -28,8 +26,6 @@ type Args = {
|
||||
|
||||
// This function is responsible for the following actions, in order:
|
||||
// - Run condition
|
||||
// - Merge original document data into incoming data
|
||||
// - Compute default values for undefined fields
|
||||
// - Execute field hooks
|
||||
// - Validate data
|
||||
// - Transform data for storage
|
||||
@@ -59,26 +55,6 @@ export const promise = async ({
|
||||
const operationLocale = req.locale || defaultLocale;
|
||||
|
||||
if (fieldAffectsData(field)) {
|
||||
if (typeof siblingData[field.name] === 'undefined') {
|
||||
// If no incoming data, but existing document data is found, merge it in
|
||||
if (typeof siblingDoc[field.name] !== 'undefined') {
|
||||
if (field.localized && typeof siblingDocWithLocales[field.name] === 'object' && siblingDocWithLocales[field.name] !== null) {
|
||||
siblingData[field.name] = cloneDataFromOriginalDoc(siblingDocWithLocales[field.name][req.locale]);
|
||||
} else {
|
||||
siblingData[field.name] = cloneDataFromOriginalDoc(siblingDoc[field.name]);
|
||||
}
|
||||
|
||||
// Otherwise compute default value
|
||||
} else if (typeof field.defaultValue !== 'undefined') {
|
||||
siblingData[field.name] = await getValueWithDefault({
|
||||
value: siblingData[field.name],
|
||||
defaultValue: field.defaultValue,
|
||||
locale: req.locale,
|
||||
user: req.user,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// skip validation if the field is localized and the incoming data is null
|
||||
if (field.localized && operationLocale !== defaultLocale) {
|
||||
if (['array', 'blocks'].includes(field.type) && siblingData[field.name] === null) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { PayloadRequest, RequestContext } from '../../../express/types';
|
||||
import { Field, fieldAffectsData, TabAsField, tabHasName, valueIsValueWithRelation } from '../../config/types';
|
||||
import getValueWithDefault from '../../getDefaultValue';
|
||||
import { cloneDataFromOriginalDoc } from '../beforeChange/cloneDataFromOriginalDoc';
|
||||
import { getExistingRowDoc } from '../beforeChange/getExistingRowDoc';
|
||||
import { traverseFields } from './traverseFields';
|
||||
|
||||
type Args<T> = {
|
||||
@@ -20,6 +23,8 @@ type Args<T> = {
|
||||
// - Sanitize incoming data
|
||||
// - Execute field hooks
|
||||
// - Execute field access control
|
||||
// - Merge original document data into incoming data
|
||||
// - Compute default values for undefined fields
|
||||
|
||||
export const promise = async <T>({
|
||||
data,
|
||||
@@ -189,6 +194,22 @@ export const promise = async <T>({
|
||||
delete siblingData[field.name];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof siblingData[field.name] === 'undefined') {
|
||||
// If no incoming data, but existing document data is found, merge it in
|
||||
if (typeof siblingDoc[field.name] !== 'undefined') {
|
||||
siblingData[field.name] = cloneDataFromOriginalDoc(siblingDoc[field.name]);
|
||||
|
||||
// Otherwise compute default value
|
||||
} else if (typeof field.defaultValue !== 'undefined') {
|
||||
siblingData[field.name] = await getValueWithDefault({
|
||||
value: siblingData[field.name],
|
||||
defaultValue: field.defaultValue,
|
||||
locale: req.locale,
|
||||
user: req.user,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Traverse subfields
|
||||
@@ -231,7 +252,7 @@ export const promise = async <T>({
|
||||
overrideAccess,
|
||||
req,
|
||||
siblingData: row,
|
||||
siblingDoc: siblingDoc[field.name]?.[i] || {},
|
||||
siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]),
|
||||
context,
|
||||
}));
|
||||
});
|
||||
@@ -258,7 +279,7 @@ export const promise = async <T>({
|
||||
overrideAccess,
|
||||
req,
|
||||
siblingData: row,
|
||||
siblingDoc: siblingDoc[field.name]?.[i] || {},
|
||||
siblingDoc: getExistingRowDoc(row, siblingDoc[field.name]),
|
||||
context,
|
||||
}));
|
||||
}
|
||||
@@ -291,8 +312,11 @@ export const promise = async <T>({
|
||||
let tabSiblingData;
|
||||
let tabSiblingDoc;
|
||||
if (tabHasName(field)) {
|
||||
tabSiblingData = typeof siblingData[field.name] === 'object' ? siblingData[field.name] : {};
|
||||
tabSiblingDoc = typeof siblingDoc[field.name] === 'object' ? siblingDoc[field.name] : {};
|
||||
if (typeof siblingData[field.name] !== 'object') siblingData[field.name] = {};
|
||||
if (typeof siblingDoc[field.name] !== 'object') siblingDoc[field.name] = {};
|
||||
|
||||
tabSiblingData = siblingData[field.name] as Record<string, unknown>;
|
||||
tabSiblingDoc = siblingDoc[field.name] as Record<string, unknown>;
|
||||
} else {
|
||||
tabSiblingData = siblingData;
|
||||
tabSiblingDoc = siblingDoc;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user