chore: merge master
This commit is contained in:
@@ -40,6 +40,7 @@ module.exports = {
|
||||
'jest/no-test-callback': 'off',
|
||||
'jest/prefer-strict-equal': 'off',
|
||||
'jest/expect-expect': 'off',
|
||||
'jest-dom/prefer-to-have-attribute': 'off',
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/BUG_REPORT.md
vendored
2
.github/ISSUE_TEMPLATE/BUG_REPORT.md
vendored
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Create a bug report for Payload
|
||||
labels: 'bug'
|
||||
labels: 'possible-bug'
|
||||
---
|
||||
|
||||
# Bug Report
|
||||
|
||||
6
.github/workflows/tests.yml
vendored
6
.github/workflows/tests.yml
vendored
@@ -1,6 +1,10 @@
|
||||
name: build
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, edited, synchronize]
|
||||
push:
|
||||
branches: ['master']
|
||||
|
||||
jobs:
|
||||
build_yarn:
|
||||
|
||||
157
CHANGELOG.md
157
CHANGELOG.md
@@ -1,3 +1,160 @@
|
||||
## [1.0.36](https://github.com/payloadcms/payload/compare/v1.0.35...v1.0.36) (2022-09-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bug with account view ([ada1871](https://github.com/payloadcms/payload/commit/ada1871993bae92bc7a30f48029b437d63eb3871))
|
||||
|
||||
## [1.0.35](https://github.com/payloadcms/payload/compare/v1.0.34...v1.0.35) (2022-09-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#1059](https://github.com/payloadcms/payload/issues/1059) ([13dc39d](https://github.com/payloadcms/payload/commit/13dc39dc6da4cb7c450477f539b09a3cb54ed5af))
|
||||
* add height/width if imageSizes not specified ([8bd2a0e](https://github.com/payloadcms/payload/commit/8bd2a0e6c9a9cd05c7b162ade47f3bb111236ba3))
|
||||
* incorrect auth strategy type ([c8b37f4](https://github.com/payloadcms/payload/commit/c8b37f40cbdc766a45dbe21573b1848bfc091901))
|
||||
* rich text link with no selection ([5a19f69](https://github.com/payloadcms/payload/commit/5a19f6915a17dbb072b89f63f32705d5f0fc75ce))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allows rich text links to link to other docs ([a99d9c9](https://github.com/payloadcms/payload/commit/a99d9c98c3f92d6fbeb65c384ca4d43b82184bfd))
|
||||
* improves rich text link ux ([91000d7](https://github.com/payloadcms/payload/commit/91000d7fdaa9628650c737fc3f7f6a900b7447d4))
|
||||
|
||||
## [1.0.34](https://github.com/payloadcms/payload/compare/v1.0.33...v1.0.34) (2022-09-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* pins faceless ui modal ([b38b642](https://github.com/payloadcms/payload/commit/b38b6427b8b813487922db0bb7d3762cc41d3447))
|
||||
|
||||
## [1.0.33](https://github.com/payloadcms/payload/compare/v1.0.30...v1.0.33) (2022-09-07)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#1062](https://github.com/payloadcms/payload/issues/1062) ([05d1b14](https://github.com/payloadcms/payload/commit/05d1b141b22f66cb9007f20f2ae9d8e31db4f32f))
|
||||
* [#948](https://github.com/payloadcms/payload/issues/948) ([8df9ee7](https://github.com/payloadcms/payload/commit/8df9ee7b2dfcb2f77f049d02788a5c60c45f8c12))
|
||||
* [#981](https://github.com/payloadcms/payload/issues/981) ([d588843](https://github.com/payloadcms/payload/commit/d58884312132e109ae3f6619be2e0d7bab3f3111))
|
||||
* accented label char sanitization for GraphQL ([#1080](https://github.com/payloadcms/payload/issues/1080)) ([888734d](https://github.com/payloadcms/payload/commit/888734dcdf775f416395f8830561c47235bb9019))
|
||||
* children of conditional fields required in graphql schema ([#1055](https://github.com/payloadcms/payload/issues/1055)) ([29e82ec](https://github.com/payloadcms/payload/commit/29e82ec845f69bf5a09b682739e88529ebc53c16))
|
||||
* ensures adding new media to upload works when existing doc does not exist ([5ae666b](https://github.com/payloadcms/payload/commit/5ae666b0e08b128bdf2d576428e8638c2b8c2ed8))
|
||||
* implement the same word boundary search as the like query ([#1038](https://github.com/payloadcms/payload/issues/1038)) ([c3a0bd8](https://github.com/payloadcms/payload/commit/c3a0bd86254dfc3f49e46d4e41bdf717424ea342))
|
||||
* reorder plugin wrapping ([#1051](https://github.com/payloadcms/payload/issues/1051)) ([cd8edba](https://github.com/payloadcms/payload/commit/cd8edbaa1faa5a94166396918089a01058a4e75e))
|
||||
* require min 1 option in field schema validation ([#1082](https://github.com/payloadcms/payload/issues/1082)) ([d56882c](https://github.com/payloadcms/payload/commit/d56882cc20764b793049f20a91864c943e711375))
|
||||
* update removing a relationship with null ([#1056](https://github.com/payloadcms/payload/issues/1056)) ([44b0073](https://github.com/payloadcms/payload/commit/44b0073834830a9d645a11bcafab3869b4eb1899))
|
||||
* update removing an upload with null ([#1076](https://github.com/payloadcms/payload/issues/1076)) ([2ee4c7a](https://github.com/payloadcms/payload/commit/2ee4c7ad727b9311578d3049660de81c27dace55))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* cyrillic like query support ([#1078](https://github.com/payloadcms/payload/issues/1078)) ([b7e5828](https://github.com/payloadcms/payload/commit/b7e5828adc7bc6602da7992b073b005b30aa896f))
|
||||
* duplicate copies all locales ([51c7770](https://github.com/payloadcms/payload/commit/51c7770b10c34a3e40520ca8d64beedc67693c5c))
|
||||
* update operator type with contains ([#1045](https://github.com/payloadcms/payload/issues/1045)) ([482cbe7](https://github.com/payloadcms/payload/commit/482cbe71c7b1d39b665fb0b29a7a0b69f454180a))
|
||||
|
||||
## [1.0.30](https://github.com/payloadcms/payload/compare/v1.0.29...v1.0.30) (2022-08-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* upload field validation not required ([#1025](https://github.com/payloadcms/payload/issues/1025)) ([689fa00](https://github.com/payloadcms/payload/commit/689fa008fb0b28fb92be4ca785a77f4c35ae16b2))
|
||||
|
||||
## [1.0.29](https://github.com/payloadcms/payload/compare/v1.0.28...v1.0.29) (2022-08-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#953](https://github.com/payloadcms/payload/issues/953) ([a73c391](https://github.com/payloadcms/payload/commit/a73c391c2cecc3acf8dc3115b56c018f85d9bebf))
|
||||
|
||||
## [1.0.28](https://github.com/payloadcms/payload/compare/v1.0.27...v1.0.28) (2022-08-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* incorrect field paths when nesting unnamed fields ([#1011](https://github.com/payloadcms/payload/issues/1011)) ([50b0303](https://github.com/payloadcms/payload/commit/50b0303ab39f0d0500c5e4116df95f02d1d7fff3)), closes [#976](https://github.com/payloadcms/payload/issues/976)
|
||||
* relationship cell loading ([#1021](https://github.com/payloadcms/payload/issues/1021)) ([6a3cfce](https://github.com/payloadcms/payload/commit/6a3cfced9a6e0ef75b398ec663f908c725b10d1a))
|
||||
* remove lazy loading of array and blocks ([4900fa7](https://github.com/payloadcms/payload/commit/4900fa799ffbeb70e689622b269dc04a67978552))
|
||||
* require properties in blocks and arrays fields ([#1020](https://github.com/payloadcms/payload/issues/1020)) ([6bc6e7b](https://github.com/payloadcms/payload/commit/6bc6e7bb616bd9f28f2464d3e55e7a1d19a8e7f8))
|
||||
* unpublish item will not crash the UI anymore ([#1016](https://github.com/payloadcms/payload/issues/1016)) ([0586d7a](https://github.com/payloadcms/payload/commit/0586d7aa7d0938df25492487aa073c2aa366e1e4))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* export more fields config types and validation type ([#989](https://github.com/payloadcms/payload/issues/989)) ([25f5d68](https://github.com/payloadcms/payload/commit/25f5d68b74b081c060ddf6f0405c9211f5da6b54))
|
||||
* types custom components to allow any props ([#1013](https://github.com/payloadcms/payload/issues/1013)) ([3736755](https://github.com/payloadcms/payload/commit/3736755a12cf5bbaaa916a5c0363026318a60823))
|
||||
* validate relationship and upload ids ([#1004](https://github.com/payloadcms/payload/issues/1004)) ([d727fc8](https://github.com/payloadcms/payload/commit/d727fc8e2467e3f438ea6b1d2031e0657bffd183))
|
||||
|
||||
## [1.0.27](https://github.com/payloadcms/payload/compare/v1.0.26...v1.0.27) (2022-08-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* react-sortable-hoc dependency instead of dev dependency ([4ef6801](https://github.com/payloadcms/payload/commit/4ef6801230cb0309a9d20dd092f8a3372f75f9ca))
|
||||
|
||||
## [1.0.26](https://github.com/payloadcms/payload/compare/v1.0.25...v1.0.26) (2022-08-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* missing fields in rows on custom id collections ([#954](https://github.com/payloadcms/payload/issues/954)) ([39586d3](https://github.com/payloadcms/payload/commit/39586d3cdb01131b29f1f8f7346086d2bc9903c1))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* adds more prismjs syntax highlighting options for code blocks ([#961](https://github.com/payloadcms/payload/issues/961)) ([f45d5a0](https://github.com/payloadcms/payload/commit/f45d5a0421117180f85f8e3cd86f835c13ac6d16))
|
||||
* enable reordering of hasMany relationship and select fields ([#952](https://github.com/payloadcms/payload/issues/952)) ([38a1a38](https://github.com/payloadcms/payload/commit/38a1a38c0c52403083458619b2f9b58044c5c0ea))
|
||||
|
||||
## [1.0.25](https://github.com/payloadcms/payload/compare/v1.0.24...v1.0.25) (2022-08-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#568](https://github.com/payloadcms/payload/issues/568) ([a3edbf4](https://github.com/payloadcms/payload/commit/a3edbf4fef5efd8293cb4d6139b2513441cb741e))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add new pickerAppearance option 'monthOnly' ([566c6ba](https://github.com/payloadcms/payload/commit/566c6ba3a9beb13ea9437844313ec6701effce27))
|
||||
* custom api endpoints ([11d8fc7](https://github.com/payloadcms/payload/commit/11d8fc71e8bdb62c6755789903702b0ee257b448))
|
||||
|
||||
## [1.0.24](https://github.com/payloadcms/payload/compare/v1.0.23...v1.0.24) (2022-08-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#939](https://github.com/payloadcms/payload/issues/939) ([b1a1575](https://github.com/payloadcms/payload/commit/b1a1575122f602ff6ba77973ab2a67893d352487))
|
||||
* create indexes in nested fields ([f615abc](https://github.com/payloadcms/payload/commit/f615abc9b1d9000aff114010ef7f618ec70b6491))
|
||||
* format graphql localization input type ([#932](https://github.com/payloadcms/payload/issues/932)) ([1c7445d](https://github.com/payloadcms/payload/commit/1c7445dc7fd883f6d5dcba532e9e048b1cff08f5))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ensures you can query on blocks via specifying locale or not specifying locale ([078e8dc](https://github.com/payloadcms/payload/commit/078e8dcc51197133788294bac6fa380b192defbc))
|
||||
|
||||
## [1.0.23](https://github.com/payloadcms/payload/compare/v1.0.22...v1.0.23) (2022-08-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#930](https://github.com/payloadcms/payload/issues/930) ([cbb1c84](https://github.com/payloadcms/payload/commit/cbb1c84be76146301ce41c4bdace647df83a4aac))
|
||||
* dev:generate-types on all test configs ([#919](https://github.com/payloadcms/payload/issues/919)) ([145e1db](https://github.com/payloadcms/payload/commit/145e1db05db0e71149ba74e95764970dfdfd8b6b))
|
||||
|
||||
## [1.0.22](https://github.com/payloadcms/payload/compare/v1.0.21...v1.0.22) (2022-08-12)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#905](https://github.com/payloadcms/payload/issues/905) ([b8421dd](https://github.com/payloadcms/payload/commit/b8421ddc0c9357de7a61bdc565fe2f9c4cf62681))
|
||||
* ensures you can query on mixed schema type within blocks ([fba0847](https://github.com/payloadcms/payload/commit/fba0847f0fbc4c144ec85bb7a1ed3f2a953f5e05))
|
||||
|
||||
## [1.0.21](https://github.com/payloadcms/payload/compare/v1.0.20...v1.0.21) (2022-08-11)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ensures you can query on nested block fields ([ca852e8](https://github.com/payloadcms/payload/commit/ca852e8cb2d78982abeae0b5db4117f0261d8fed))
|
||||
* saving multiple versions ([#918](https://github.com/payloadcms/payload/issues/918)) ([d0da3d7](https://github.com/payloadcms/payload/commit/d0da3d7962bbddfbdc1c553816409823bf6e1335))
|
||||
|
||||
## [1.0.20](https://github.com/payloadcms/payload/compare/v1.0.19...v1.0.20) (2022-08-11)
|
||||
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ export { default as Button } from '../dist/admin/components/elements/Button';
|
||||
export { default as Card } from '../dist/admin/components/elements/Card';
|
||||
export { default as Eyebrow } from '../dist/admin/components/elements/Eyebrow';
|
||||
export { default as Nav } from '../dist/admin/components/elements/Nav';
|
||||
export { default as Gutter } from '../dist/admin/components/elements/Gutter';
|
||||
export { Gutter } from '../dist/admin/components/elements/Gutter';
|
||||
|
||||
@@ -27,8 +27,10 @@ If a Collection supports [`Authentication`](/docs/authentication/overview), the
|
||||
| **[`unlock`](#unlock)** | Used to restrict which users can access the `unlock` operation |
|
||||
|
||||
**Example Collection config:**
|
||||
```js
|
||||
export default {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Posts: CollectionConfig = {
|
||||
slug: "posts",
|
||||
// highlight-start
|
||||
access: {
|
||||
@@ -40,6 +42,8 @@ export default {
|
||||
},
|
||||
// highlight-end
|
||||
};
|
||||
|
||||
export default Categories;
|
||||
```
|
||||
|
||||
### Create
|
||||
@@ -55,7 +59,7 @@ Returns a boolean which allows/denies access to the `create` request.
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
```ts
|
||||
const PublicUsers = {
|
||||
slug: 'public-users',
|
||||
access: {
|
||||
@@ -82,8 +86,10 @@ Read access functions can return a boolean result or optionally return a [query
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
const canReadPage = ({ req: { user } }) => {
|
||||
```ts
|
||||
import { Access } from 'payload/config';
|
||||
|
||||
const canReadPage: Access = ({ req: { user } }) => {
|
||||
// allow authenticated users
|
||||
if (user) {
|
||||
return true;
|
||||
@@ -92,8 +98,8 @@ const canReadPage = ({ req: { user } }) => {
|
||||
return {
|
||||
// assumes we have a checkbox field named 'isPublic'
|
||||
isPublic: {
|
||||
equals: true
|
||||
}
|
||||
equals: true,
|
||||
},
|
||||
}
|
||||
};
|
||||
```
|
||||
@@ -112,11 +118,12 @@ Update access functions can return a boolean result or optionally return a [quer
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { Access } from 'payload/config';
|
||||
|
||||
const canUpdateUser = ({ req: { user }, id }) => {
|
||||
const canUpdateUser: Access = ({ req: { user }, id }) => {
|
||||
// allow users with a role of 'admin'
|
||||
if (user.roles && user.roles.some((role) => role === 'admin')) {
|
||||
if (user.roles && user.roles.some(role => role === 'admin')) {
|
||||
return true;
|
||||
}
|
||||
// allow any other users to update only oneself
|
||||
@@ -137,8 +144,10 @@ Similarly to the Update function, returns a boolean or a [query constraint](/doc
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
const canDeleteCustomer = async ({ req, id }) => {
|
||||
```ts
|
||||
import { Access } from 'payload/config'
|
||||
|
||||
const canDeleteCustomer: Access = async ({ req, id }) => {
|
||||
if (!id) {
|
||||
// allow the admin UI to show controls to delete since it is indeterminate without the id
|
||||
return true;
|
||||
|
||||
@@ -17,8 +17,10 @@ Field Access Control is specified with functions inside a field's config. All fi
|
||||
| **[`update`](#update)** | Allows or denies the ability to update a field's value |
|
||||
|
||||
**Example Collection config:**
|
||||
```js
|
||||
export default {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
fields: [
|
||||
{
|
||||
@@ -33,7 +35,7 @@ export default {
|
||||
// highlight-end
|
||||
};
|
||||
],
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Create
|
||||
|
||||
@@ -18,16 +18,20 @@ You can define Global-level Access Control within each Global's `access` propert
|
||||
| **[`update`](#update)** | Used in the `update` Global operation |
|
||||
|
||||
**Example Global config:**
|
||||
```js
|
||||
export default {
|
||||
```ts
|
||||
import { GlobalConfig } from 'payload/types';
|
||||
|
||||
const Header: GlobalConfig = {
|
||||
slug: "header",
|
||||
// highlight-start
|
||||
access: {
|
||||
read: ({ req: { user } }) => { ... },
|
||||
update: ({ req: { user } }) => { ... },
|
||||
read: ({ req: { user } }) => { /* */ },
|
||||
update: ({ req: { user } }) => { /* */ },
|
||||
},
|
||||
// highlight-end
|
||||
};
|
||||
|
||||
export default Header;
|
||||
```
|
||||
|
||||
### Read
|
||||
|
||||
@@ -23,7 +23,7 @@ Access control within Payload is extremely powerful while remaining easy and int
|
||||
|
||||
**Default Access function:**
|
||||
|
||||
```js
|
||||
```ts
|
||||
const defaultPayloadAccess = ({ req: { user } }) => {
|
||||
// Return `true` if a user is found
|
||||
// and `false` if it is undefined or null
|
||||
|
||||
@@ -38,9 +38,16 @@ You can override a set of admin panel-wide components by providing a component t
|
||||
#### Full example:
|
||||
|
||||
`payload.config.js`
|
||||
```js
|
||||
import { buildConfig } from 'payload/config';
|
||||
import { MyCustomNav, MyCustomLogo, MyCustomIcon, MyCustomAccount, MyCustomDashboard, MyProvider } from './customComponents.js';
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config'
|
||||
import {
|
||||
MyCustomNav,
|
||||
MyCustomLogo,
|
||||
MyCustomIcon,
|
||||
MyCustomAccount,
|
||||
MyCustomDashboard,
|
||||
MyProvider,
|
||||
} from './customComponents';
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
@@ -55,9 +62,9 @@ export default buildConfig({
|
||||
Dashboard: MyCustomDashboard,
|
||||
},
|
||||
providers: [MyProvider],
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
*For more examples regarding how to customize components, look at the following [examples](https://github.com/payloadcms/payload/tree/master/test/admin/components).*
|
||||
@@ -100,20 +107,17 @@ All Payload fields support the ability to swap in your own React components. So,
|
||||
|
||||
When swapping out the `Field` component, you'll be responsible for sending and receiving the field's `value` from the form itself. To do so, import the `useField` hook as follows:
|
||||
|
||||
```js
|
||||
import { useField } from 'payload/components/forms';
|
||||
```tsx
|
||||
import { useField } from 'payload/components/forms'
|
||||
|
||||
const CustomTextField = ({ path }) => {
|
||||
type Props = { path: string }
|
||||
|
||||
const CustomTextField: React.FC<Props> = ({ path }) => {
|
||||
// highlight-start
|
||||
const { value, setValue } = useField({ path });
|
||||
const { value, setValue } = useField<Props>({ path })
|
||||
// highlight-end
|
||||
|
||||
return (
|
||||
<input
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
return <input onChange={e => setValue(e.target.value)} value={value.path} />
|
||||
}
|
||||
```
|
||||
|
||||
@@ -121,10 +125,10 @@ const CustomTextField = ({ path }) => {
|
||||
|
||||
There are times when a custom field component needs to have access to data from other fields. This can be done using `getDataByPath` from `useWatchForm` as follows:
|
||||
|
||||
```js
|
||||
```tsx
|
||||
import { useWatchForm } from 'payload/components/forms';
|
||||
|
||||
const DisplayFee = () => {
|
||||
const DisplayFee: React.FC = () => {
|
||||
const { getDataByPath } = useWatchForm();
|
||||
|
||||
const amount = getDataByPath('amount');
|
||||
@@ -132,7 +136,7 @@ const DisplayFee = () => {
|
||||
|
||||
if (amount && feePercentage) {
|
||||
return (
|
||||
<span>The fee is ${ amount * feePercentage / 100 }</span>
|
||||
<span>The fee is ${(amount * feePercentage) / 100}</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -142,10 +146,10 @@ const DisplayFee = () => {
|
||||
|
||||
The document ID can be very useful for certain custom components. You can get the `id` from the `useDocumentInfo` hook. Here is an example of a `UI` field using `id` to link to related collections:
|
||||
|
||||
```js
|
||||
```tsx
|
||||
import { useDocumentInfo } from 'payload/components/utilities';
|
||||
|
||||
const LinkFromCategoryToPosts = () => {
|
||||
const LinkFromCategoryToPosts: React.FC = () => {
|
||||
// highlight-start
|
||||
const { id } = useDocumentInfo();
|
||||
// highlight-end
|
||||
@@ -222,10 +226,10 @@ To make use of Payload SCSS variables / mixins to use directly in your own compo
|
||||
|
||||
In any custom component you can get the selected locale with the `useLocale` hook. Here is a simple example:
|
||||
|
||||
```js
|
||||
```tsx
|
||||
import { useLocale } from 'payload/components/utilities';
|
||||
|
||||
const Greeting = () => {
|
||||
const Greeting: React.FC = () => {
|
||||
// highlight-start
|
||||
const locale = useLocale();
|
||||
// highlight-end
|
||||
@@ -237,6 +241,6 @@ const Greeting = () => {
|
||||
|
||||
return (
|
||||
<span> { trans[locale] } </span>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@ You can add your own CSS by providing your base Payload config with a path to yo
|
||||
To do so, provide your base Payload config with a path to your own stylesheet. It can be either a CSS or SCSS file.
|
||||
|
||||
**Example in payload.config.js:**
|
||||
```js
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config';
|
||||
import path from 'path';
|
||||
|
||||
@@ -21,7 +21,7 @@ const config = buildConfig({
|
||||
admin: {
|
||||
css: path.resolve(__dirname, 'relative/path/to/stylesheet.scss'),
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### Overriding built-in styles
|
||||
|
||||
@@ -45,14 +45,14 @@ All options for the Admin panel are defined in your base Payload config file.
|
||||
To specify which Collection to use to log in to the Admin panel, pass the `admin` options a `user` key equal to the slug of the Collection that you'd like to use.
|
||||
|
||||
`payload.config.js`:
|
||||
```js
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config';
|
||||
|
||||
const config = buildConfig({
|
||||
admin: {
|
||||
user: 'admins', // highlight-line
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
By default, if you have not specified a Collection, Payload will automatically provide you with a `User` Collection which will be used to access the Admin panel. You can customize or override the fields and settings of the default `User` Collection by passing your own collection using `users` as its `slug` to Payload. When this is done, Payload will use your provided `User` Collection instead of its default version.
|
||||
|
||||
@@ -10,8 +10,8 @@ Payload uses Webpack 5 to build the Admin panel. It comes with support for many
|
||||
|
||||
To extend the Webpack config, add the `webpack` key to your base Payload config, and provide a function that accepts the default Webpack config as its only argument:
|
||||
|
||||
`payload.config.js`
|
||||
```js
|
||||
`payload.config.ts`
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config';
|
||||
|
||||
export default buildConfig({
|
||||
@@ -24,7 +24,7 @@ export default buildConfig({
|
||||
}
|
||||
// highlight-end
|
||||
}
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
### Aliasing server-only modules
|
||||
@@ -52,16 +52,17 @@ You may rely on server-only packages such as the above to perform logic in acces
|
||||
<br/><br/>
|
||||
|
||||
`collections/Subscriptions/index.js`
|
||||
```js
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
import createStripeSubscription from './hooks/createStripeSubscription';
|
||||
|
||||
const Subscription = {
|
||||
const Subscription: CollectionConfig = {
|
||||
slug: 'subscriptions',
|
||||
hooks: {
|
||||
beforeChange: [
|
||||
createStripeSubscription,
|
||||
]
|
||||
}
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'stripeSubscriptionID',
|
||||
@@ -69,7 +70,7 @@ const Subscription = {
|
||||
required: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default Subscription;
|
||||
```
|
||||
|
||||
@@ -49,7 +49,7 @@ To utilize your API key while interacting with the REST or GraphQL API, add the
|
||||
|
||||
**For example, using Fetch:**
|
||||
|
||||
```js
|
||||
```ts
|
||||
const response = await fetch("http://localhost:3000/api/pages", {
|
||||
headers: {
|
||||
Authorization: `${collection.labels.singular} API-Key ${YOUR_API_KEY}`,
|
||||
@@ -77,8 +77,10 @@ Function that accepts one argument, containing `{ req, token, user }`, that allo
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
{
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Customers: CollectionConfig = {
|
||||
slug: 'customers',
|
||||
auth: {
|
||||
forgotPassword: {
|
||||
@@ -104,7 +106,7 @@ Example:
|
||||
// highlight-end
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
<Banner type="warning">
|
||||
@@ -123,7 +125,7 @@ Similarly to the above `generateEmailHTML`, you can also customize the subject o
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
slug: 'customers',
|
||||
auth: {
|
||||
@@ -148,8 +150,11 @@ Function that accepts one argument, containing `{ req, token, user }`, that allo
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
{
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
|
||||
const Customers: CollectionConfig = {
|
||||
slug: 'customers',
|
||||
auth: {
|
||||
verify: {
|
||||
@@ -163,7 +168,7 @@ Example:
|
||||
// highlight-end
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
<Banner type="warning">
|
||||
@@ -182,7 +187,7 @@ Similarly to the above `generateEmailHTML`, you can also customize the subject o
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
slug: 'customers',
|
||||
auth: {
|
||||
|
||||
@@ -17,7 +17,7 @@ The Access operation returns what a logged in user can and can't do with the col
|
||||
`GET http://localhost:3000/api/access`
|
||||
|
||||
Example response:
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
canAccessAdmin: true,
|
||||
collections: {
|
||||
@@ -54,7 +54,7 @@ Example response:
|
||||
|
||||
**Example GraphQL Query**:
|
||||
|
||||
```
|
||||
```graphql
|
||||
query {
|
||||
Access {
|
||||
pages {
|
||||
@@ -75,7 +75,7 @@ Returns either a logged in user with token or null when there is no logged in us
|
||||
`GET http://localhost:3000/api/[collection-slug]/me`
|
||||
|
||||
Example response:
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
user: { // The JWT "payload" ;) from the logged in user
|
||||
email: 'dev@payloadcms.com',
|
||||
@@ -90,7 +90,7 @@ Example response:
|
||||
|
||||
**Example GraphQL Query**:
|
||||
|
||||
```
|
||||
```graphql
|
||||
query {
|
||||
Me[collection-singular-label] {
|
||||
user {
|
||||
@@ -106,7 +106,7 @@ query {
|
||||
Accepts an `email` and `password`. On success, it will return the logged in user as well as a token that can be used to authenticate. In the GraphQL and REST APIs, this operation also automatically sets an HTTP-only cookie including the user's token. If you pass an Express `res` to the Local API operation, Payload will set a cookie there as well.
|
||||
|
||||
**Example REST API login**:
|
||||
```js
|
||||
```ts
|
||||
const res = await fetch('http://localhost:3000/api/[collection-slug]/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -137,7 +137,7 @@ const json = await res.json();
|
||||
|
||||
**Example GraphQL Mutation**:
|
||||
|
||||
```
|
||||
```graphql
|
||||
mutation {
|
||||
login[collection-singular-label](email: "dev@payloadcms.com", password: "yikes") {
|
||||
user {
|
||||
@@ -151,7 +151,7 @@ mutation {
|
||||
|
||||
**Example Local API login**:
|
||||
|
||||
```js
|
||||
```ts
|
||||
const result = await payload.login({
|
||||
collection: '[collection-slug]',
|
||||
data: {
|
||||
@@ -166,7 +166,7 @@ const result = await payload.login({
|
||||
As Payload sets HTTP-only cookies, logging out cannot be done by just removing a cookie in JavaScript, as HTTP-only cookies are inaccessible by JS within the browser. So, Payload exposes a `logout` operation to delete the token in a safe way.
|
||||
|
||||
**Example REST API logout**:
|
||||
```js
|
||||
```ts
|
||||
const res = await fetch('http://localhost:3000/api/[collection-slug]/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -192,7 +192,7 @@ This operation requires a non-expired token to send back a new one. If the user'
|
||||
If successful, this operation will automatically renew the user's HTTP-only cookie and will send back the updated token in JSON.
|
||||
|
||||
**Example REST API token refresh**:
|
||||
```js
|
||||
```ts
|
||||
const res = await fetch('http://localhost:3000/api/[collection-slug]/refresh-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -239,18 +239,18 @@ mutation {
|
||||
If your collection supports email verification, the Verify operation will be exposed which accepts a verification token and sets the user's `_verified` property to `true`, thereby allowing the user to authenticate with the Payload API.
|
||||
|
||||
**Example REST API user verification**:
|
||||
```js
|
||||
```ts
|
||||
const res = await fetch(`http://localhost:3000/api/[collection-slug]/verify/${TOKEN_HERE}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Example GraphQL Mutation**:
|
||||
|
||||
```
|
||||
```graphql
|
||||
mutation {
|
||||
verifyEmail[collection-singular-label](token: "TOKEN_HERE")
|
||||
}
|
||||
@@ -258,7 +258,7 @@ mutation {
|
||||
|
||||
**Example Local API verification**:
|
||||
|
||||
```js
|
||||
```ts
|
||||
const result = await payload.verifyEmail({
|
||||
collection: '[collection-slug]',
|
||||
token: 'TOKEN_HERE',
|
||||
@@ -272,7 +272,7 @@ If a user locks themselves out and you wish to deliberately unlock them, you can
|
||||
To restrict who is allowed to unlock users, you can utilize the [`unlock`](/docs/access-control/overview#unlock) access control function.
|
||||
|
||||
**Example REST API unlock**:
|
||||
```js
|
||||
```ts
|
||||
const res = await fetch(`http://localhost:3000/api/[collection-slug]/unlock`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -291,7 +291,7 @@ mutation {
|
||||
|
||||
**Example Local API unlock**:
|
||||
|
||||
```js
|
||||
```ts
|
||||
const result = await payload.unlock({
|
||||
collection: '[collection-slug]',
|
||||
})
|
||||
@@ -306,7 +306,7 @@ The link to reset the user's password contains a token which is what allows the
|
||||
By default, the Forgot Password operations send users to the Payload Admin panel to reset their password, but you can customize the generated email to send users to the frontend of your app instead by [overriding the email HTML](/docs/authentication/config#forgot-password).
|
||||
|
||||
**Example REST API Forgot Password**:
|
||||
```js
|
||||
```ts
|
||||
const res = await fetch(`http://localhost:3000/api/[collection-slug]/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -315,7 +315,7 @@ const res = await fetch(`http://localhost:3000/api/[collection-slug]/forgot-pass
|
||||
body: JSON.stringify({
|
||||
email: 'dev@payloadcms.com',
|
||||
}),
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
**Example GraphQL Mutation**:
|
||||
@@ -328,14 +328,14 @@ mutation {
|
||||
|
||||
**Example Local API forgot password**:
|
||||
|
||||
```js
|
||||
```ts
|
||||
const token = await payload.forgotPassword({
|
||||
collection: '[collection-slug]',
|
||||
data: {
|
||||
email: 'dev@payloadcms.com',
|
||||
},
|
||||
disableEmail: false // you can disable the auto-generation of email via local API
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
<Banner type="success">
|
||||
@@ -348,7 +348,7 @@ const token = await payload.forgotPassword({
|
||||
After a user has "forgotten" their password and a token is generated, that token can be used to send to the reset password operation along with a new password which will allow the user to reset their password securely.
|
||||
|
||||
**Example REST API Reset Password**:
|
||||
```js
|
||||
```ts
|
||||
const res = await fetch(`http://localhost:3000/api/[collection-slug]/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -358,7 +358,7 @@ const res = await fetch(`http://localhost:3000/api/[collection-slug]/reset-passw
|
||||
token: 'TOKEN_GOES_HERE'
|
||||
password: 'not-today',
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
@@ -379,7 +379,7 @@ const json = await res.json();
|
||||
|
||||
**Example GraphQL Mutation**:
|
||||
|
||||
```
|
||||
```graphql
|
||||
mutation {
|
||||
resetPassword[collection-singular-label](token: "TOKEN_GOES_HERE", password: "not-today")
|
||||
}
|
||||
|
||||
@@ -32,8 +32,10 @@ Every Payload Collection can opt-in to supporting Authentication by specifying t
|
||||
|
||||
Simple example collection:
|
||||
|
||||
```js
|
||||
const Admins = {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Admins: CollectionConfig = {
|
||||
slug:
|
||||
// highlight-start
|
||||
auth: {
|
||||
@@ -60,7 +62,7 @@ const Admins = {
|
||||
}
|
||||
```
|
||||
|
||||
**By enabling Authetication on a config, the following modifications will automatically be made to your Collection:**
|
||||
**By enabling Authentication on a config, the following modifications will automatically be made to your Collection:**
|
||||
|
||||
1. `email` as well as password `salt` & `hash` fields will be added to your Collection's schema
|
||||
1. The Admin panel will feature a new set of corresponding UI to allow for changing password and editing email
|
||||
@@ -95,7 +97,7 @@ However, if you use `fetch` or similar APIs to retrieve Payload resources from i
|
||||
|
||||
Fetch example, including credentials:
|
||||
|
||||
```js
|
||||
```ts
|
||||
const response = await fetch('http://localhost:3000/api/pages', {
|
||||
credentials: 'include',
|
||||
});
|
||||
@@ -124,8 +126,8 @@ So, if a user of coolsite.com is logged in and just browsing around on the inter
|
||||
|
||||
To define domains that should allow users to identify themselves via the Payload HTTP-only cookie, use the `csrf` option on the base Payload config to whitelist domains that you trust.
|
||||
|
||||
`payload.config.js`:
|
||||
```js
|
||||
`payload.config.ts`:
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config';
|
||||
|
||||
const config = buildConfig({
|
||||
@@ -148,7 +150,7 @@ export default config;
|
||||
In addition to authenticating via an HTTP-only cookie, you can also identify users via the `Authorization` header on an HTTP request.
|
||||
|
||||
Example:
|
||||
```js
|
||||
```ts
|
||||
const request = await fetch('http://localhost:3000', {
|
||||
headers: {
|
||||
Authorization: `JWT ${token}`
|
||||
|
||||
@@ -15,7 +15,7 @@ This approach has a ton of benefits - it's great for isolation of concerns and l
|
||||
</Banner>
|
||||
|
||||
Example in `server.js`:
|
||||
```js
|
||||
```ts
|
||||
import express from 'express';
|
||||
import payload from 'payload';
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ It's often best practice to write your Collections in separate files and then im
|
||||
| **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Collection. |
|
||||
| **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Collection. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. |
|
||||
| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. |
|
||||
| **`description`**| Text or React component to display below the Collection label in the List view to give editors more information. |
|
||||
| **`admin`** | Admin-specific configuration. See below for [more detail](#admin-options). |
|
||||
| **`hooks`** | Entry points to "tie in" to Collection actions at specific points. [More](/docs/hooks/overview#collection-hooks) |
|
||||
| **`access`** | Provide access control functions to define exactly who should be able to do what with Documents in this Collection. [More](/docs/access-control/overview/#collections) |
|
||||
@@ -31,8 +30,10 @@ It's often best practice to write your Collections in separate files and then im
|
||||
|
||||
#### Simple collection example
|
||||
|
||||
```js
|
||||
const Orders = {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Orders: CollectionConfig = {
|
||||
slug: 'orders',
|
||||
fields: [
|
||||
{
|
||||
@@ -47,7 +48,7 @@ const Orders = {
|
||||
required: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### More collection config examples
|
||||
@@ -61,6 +62,7 @@ You can customize the way that the Admin panel behaves on a collection-by-collec
|
||||
| Option | Description |
|
||||
| ---------------------------- | -------------|
|
||||
| `useAsTitle` | Specify a top-level field to use for a document title throughout the Admin panel. If no field is defined, the ID of the document is used as the title. |
|
||||
| `description` | Text or React component to display below the Collection label in the List view to give editors more information. |
|
||||
| `defaultColumns` | Array of field names that correspond to which columns to show by default in this collection's List view. |
|
||||
| `disableDuplicate ` | Disables the "Duplicate" button while editing documents within this collection. |
|
||||
| `enableRichTextRelationship` | The [Rich Text](/docs/fields/rich-text) field features a `Relationship` element which allows for users to automatically reference related documents within their rich text. Set to `true` by default. |
|
||||
@@ -80,8 +82,10 @@ If the function is specified, a Preview button will automatically appear in the
|
||||
|
||||
**Example collection with preview function:**
|
||||
|
||||
```js
|
||||
const Posts = {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Posts: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
fields: [
|
||||
{
|
||||
@@ -118,14 +122,14 @@ Collections support all field types that Payload has to offer—including simple
|
||||
|
||||
You can import collection types as follows:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
// This is the type used for incoming collection configs.
|
||||
// Only the bare minimum properties are marked as required.
|
||||
```
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { SanitizedCollectionConfig } from 'payload/types';
|
||||
|
||||
// This is the type used after an incoming collection config is fully sanitized.
|
||||
|
||||
@@ -28,8 +28,10 @@ As with Collection configs, it's often best practice to write your Globals in se
|
||||
|
||||
#### Simple Global example
|
||||
|
||||
```js
|
||||
const Nav = {
|
||||
```ts
|
||||
import { GlobalConfig } from 'payload/types';
|
||||
|
||||
const Nav: GlobalConfig = {
|
||||
slug: 'nav',
|
||||
fields: [
|
||||
{
|
||||
@@ -47,7 +49,9 @@ const Nav = {
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default Nav;
|
||||
```
|
||||
|
||||
#### Global config example
|
||||
@@ -78,14 +82,14 @@ Globals support all field types that Payload has to offer—including simple fie
|
||||
|
||||
You can import global types as follows:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { GlobalConfig } from 'payload/types';
|
||||
|
||||
// This is the type used for incoming global configs.
|
||||
// Only the bare minimum properties are marked as required.
|
||||
```
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { SanitizedGlobalConfig } from 'payload/types';
|
||||
|
||||
// This is the type used after an incoming global config is fully sanitized.
|
||||
|
||||
@@ -14,21 +14,23 @@ Add the `localization` property to your Payload config to enable localization pr
|
||||
|
||||
**Example Payload config set up for localization:**
|
||||
|
||||
```js
|
||||
{
|
||||
collections: [
|
||||
... // collections go here
|
||||
],
|
||||
localization: {
|
||||
locales: [
|
||||
'en',
|
||||
'es',
|
||||
'de',
|
||||
],
|
||||
defaultLocale: 'en',
|
||||
fallback: true,
|
||||
},
|
||||
}
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config'
|
||||
|
||||
export default buildConfig({
|
||||
collections: [
|
||||
// collections go here
|
||||
],
|
||||
localization: {
|
||||
locales: [
|
||||
'en',
|
||||
'es',
|
||||
'de',
|
||||
],
|
||||
defaultLocale: 'en',
|
||||
fallback: true,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Here is a brief explanation of each of the options available within the `localization` property:**
|
||||
@@ -53,11 +55,11 @@ Payload localization works on a **field** level—not a document level. In addit
|
||||
|
||||
```js
|
||||
{
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
// highlight-start
|
||||
localized: true,
|
||||
// highlight-end
|
||||
name: 'title',
|
||||
type: 'text',
|
||||
// highlight-start
|
||||
localized: true,
|
||||
// highlight-end
|
||||
}
|
||||
```
|
||||
|
||||
@@ -66,8 +68,8 @@ With the above configuration, the `title` field will now be saved in the databas
|
||||
All field types with a `name` property support the `localized` property—even the more complex field types like `array`s and `block`s.
|
||||
|
||||
<Banner>
|
||||
<strong>Note:</strong><br/>
|
||||
Enabling localization for field types that support nested fields will automatically create localized "sets" of all fields contained within the field. For example, if you have a page layout using a blocks field type, you have the choice of either localizing the full layout, by enabling localization on the top-level blocks field, or only certain fields within the layout.
|
||||
<strong>Note:</strong><br/>
|
||||
Enabling localization for field types that support nested fields will automatically create localized "sets" of all fields contained within the field. For example, if you have a page layout using a blocks field type, you have the choice of either localizing the full layout, by enabling localization on the top-level blocks field, or only certain fields within the layout.
|
||||
</Banner>
|
||||
|
||||
### Retrieving localized docs
|
||||
@@ -104,16 +106,16 @@ The `fallbackLocale` arg will accept valid locales as well as `none` to disable
|
||||
|
||||
```graphql
|
||||
query {
|
||||
Posts(locale: de, fallbackLocale: none) {
|
||||
docs {
|
||||
title
|
||||
}
|
||||
}
|
||||
Posts(locale: de, fallbackLocale: none) {
|
||||
docs {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Banner>
|
||||
In GraphQL, specifying the locale at the top level of a query will automatically apply it throughout all nested relationship fields. You can override this behavior by re-specifying locale arguments in nested related document queries.
|
||||
In GraphQL, specifying the locale at the top level of a query will automatically apply it throughout all nested relationship fields. You can override this behavior by re-specifying locale arguments in nested related document queries.
|
||||
</Banner>
|
||||
|
||||
##### Local API
|
||||
@@ -124,9 +126,9 @@ You can specify `locale` as well as `fallbackLocale` within the Local API as wel
|
||||
|
||||
```js
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
locale: 'es',
|
||||
fallbackLocale: false,
|
||||
collection: 'posts',
|
||||
locale: 'es',
|
||||
fallbackLocale: false,
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -39,13 +39,14 @@ Payload is a *config-based*, code-first CMS and application framework. The Paylo
|
||||
| `rateLimit` | Control IP-based rate limiting for all Payload resources. Used to prevent DDoS attacks and [more](/docs/production/preventing-abuse#rate-limiting-requests). |
|
||||
| `hooks` | Tap into Payload-wide hooks. [More](/docs/hooks/overview) |
|
||||
| `plugins` | An array of Payload plugins. [More](/docs/plugins/overview) |
|
||||
| `endpoints` | An array of custom API endpoints added to the Payload router. [More](/docs/rest-api/overview#custom-endpoints) |
|
||||
|
||||
#### Simple example
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config';
|
||||
|
||||
const config = buildConfig({
|
||||
export default buildConfig({
|
||||
collections: [
|
||||
{
|
||||
slug: 'pages',
|
||||
@@ -82,9 +83,6 @@ const config = buildConfig({
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export default config;
|
||||
|
||||
```
|
||||
|
||||
#### Full example config
|
||||
@@ -173,14 +171,14 @@ Then, you could import this file into both your Payload config and your server,
|
||||
|
||||
You can import config types as follows:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { Config } from 'payload/config';
|
||||
|
||||
// This is the type used for an incoming Payload config.
|
||||
// Only the bare minimum properties are marked as required.
|
||||
```
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { SanitizedConfig } from 'payload/config';
|
||||
|
||||
// This is the type used after an incoming Payload config is fully sanitized.
|
||||
|
||||
@@ -40,7 +40,7 @@ The following options are configurable in the `email` property object as part of
|
||||
Simple Mail Transfer Protocol, also known as SMTP can be passed in using the `transportOptions` object on the `email` options.
|
||||
|
||||
**Example email part using SMTP:**
|
||||
```js
|
||||
```ts
|
||||
payload.init({
|
||||
email: {
|
||||
transportOptions: {
|
||||
@@ -60,6 +60,7 @@ payload.init({
|
||||
fromAddress: 'hello@example.com'
|
||||
}
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
<Banner type="warning">
|
||||
@@ -70,9 +71,9 @@ payload.init({
|
||||
|
||||
Many third party mail providers are available and offer benefits beyond basic SMTP. As an example your payload init could look this if you wanted to use SendGrid.com though the same approach would work for any other [NodeMailer transports](https://nodemailer.com/transports/) shown here or provided by another third party.
|
||||
|
||||
```js
|
||||
const nodemailerSendgrid = require('nodemailer-sendgrid');
|
||||
const payload = require('payload');
|
||||
```ts
|
||||
import payload from 'payload'
|
||||
import nodemailerSendgrid from 'nodemailer-sendgrid'
|
||||
|
||||
const sendGridAPIKey = process.env.SENDGRID_API_KEY;
|
||||
|
||||
@@ -92,7 +93,10 @@ payload.init({
|
||||
### Use a custom NodeMailer transport
|
||||
To take full control of the mail transport you may wish to use `nodemailer.createTransport()` on your server and provide it to Payload init.
|
||||
|
||||
```js
|
||||
```ts
|
||||
import payload from 'payload'
|
||||
import nodemailer from 'nodemailer'
|
||||
|
||||
const payload = require('payload');
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
@@ -112,7 +116,7 @@ payload.init({
|
||||
transport
|
||||
},
|
||||
// ...
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Sending Mail
|
||||
@@ -123,7 +127,7 @@ By default, Payload uses a mock implementation that only sends mail to the [ethe
|
||||
|
||||
To see ethereal credentials, add `logMockCredentials: true` to the email options. This will cause them to be logged to console on startup.
|
||||
|
||||
```js
|
||||
```ts
|
||||
payload.init({
|
||||
email: {
|
||||
fromName: 'Admin',
|
||||
|
||||
@@ -41,9 +41,11 @@ keywords: array, fields, config, configuration, documentation, Content Managemen
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -70,6 +72,5 @@ keywords: array, fields, config, configuration, documentation, Content Managemen
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
};
|
||||
```
|
||||
|
||||
@@ -72,8 +72,10 @@ The Admin panel provides each block with a `blockName` field which optionally al
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
const QuoteBlock = {
|
||||
```ts
|
||||
import { Block, CollectionConfig } from 'payload/types';
|
||||
|
||||
const QuoteBlock: Block = {
|
||||
slug: 'Quote', // required
|
||||
imageURL: 'https://google.com/path/to/image.jpg',
|
||||
imageAltText: 'A nice thumbnail image to show what this block looks like',
|
||||
@@ -90,7 +92,7 @@ const QuoteBlock = {
|
||||
]
|
||||
};
|
||||
|
||||
const ExampleCollection = {
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -111,7 +113,7 @@ const ExampleCollection = {
|
||||
|
||||
As you build your own Block configs, you might want to store them in separate files but retain typing accordingly. To do so, you can import and use Payload's `Block` type:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import type { Block } from 'payload/types';
|
||||
|
||||
```
|
||||
|
||||
@@ -31,9 +31,11 @@ keywords: checkbox, fields, config, configuration, documentation, Content Manage
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -43,6 +45,5 @@ keywords: checkbox, fields, config, configuration, documentation, Content Manage
|
||||
defaultValue: false,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
};
|
||||
```
|
||||
|
||||
@@ -39,13 +39,27 @@ This field uses `prismjs` for syntax highlighting and `react-simple-code-editor`
|
||||
|
||||
In addition to the default [field admin config](/docs/fields/overview#admin-config), the Code field type also allows for the customization of a `language` property.
|
||||
|
||||
Currently, the `language` property only supports JavaScript syntax but more support will be added as requested.
|
||||
The following `prismjs` plugins are imported, enabling the `language` property to accept the following values:
|
||||
|
||||
| Plugin | Language |
|
||||
| ---------------------------- | ----------- |
|
||||
| **`prism-css`** | `css` |
|
||||
| **`prism-clike`** | `clike` |
|
||||
| **`prism-markup`** | `markup`, `html`, `xml`, `svg`, `mathml`, `ssml`, `atom`, `rss` |
|
||||
| **`prism-javascript`** | `javascript`, `js` |
|
||||
| **`prism-json`** | `json` |
|
||||
| **`prism-jsx`** | `jsx` |
|
||||
| **`prism-typescript`** | `typescript`, `ts` |
|
||||
| **`prism-tsx`** | `tsx` |
|
||||
| **`prism-yaml`** | `yaml`, `yml` |
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -57,6 +71,5 @@ Currently, the `language` property only supports JavaScript syntax but more supp
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
};
|
||||
```
|
||||
|
||||
@@ -22,9 +22,11 @@ keywords: row, fields, config, configuration, documentation, Content Management
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -39,6 +41,5 @@ keywords: row, fields, config, configuration, documentation, Content Management
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
};
|
||||
```
|
||||
|
||||
@@ -38,8 +38,8 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf
|
||||
|
||||
| Option | Description |
|
||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`pickerAppearance`** | Determines the appearance of the datepicker: `dayAndTime` `timeOnly` `dayOnly`. Defaults to `dayAndTime`. |
|
||||
| **`displayFormat`** | Determines how the date is presented. dayAndTime default to `MMM d, yyy h:mm a` timeOnly defaults to `h:mm a` and dayOnly defaults to `MMM d, yyy`. |
|
||||
| **`pickerAppearance`** | Determines the appearance of the datepicker: `dayAndTime` `timeOnly` `dayOnly` `monthOnly`. Defaults to `dayAndTime`. |
|
||||
| **`displayFormat`** | Determines how the date is presented. dayAndTime default to `MMM d, yyy h:mm a` timeOnly defaults to `h:mm a` dayOnly defaults to `MMM d, yyy` and monthOnly defaults to `MM/yyyy`. |
|
||||
| **`placeholder`** | Placeholder text for the field. |
|
||||
| **`monthsToShow`** | Number of months to display max is 2. Defaults to 1. |
|
||||
| **`minDate`** | Passed directly to [react-datepicker](https://github.com/Hacker0x01/react-datepicker/blob/master/docs/datepicker.md). |
|
||||
@@ -55,10 +55,12 @@ Common use cases for customizing the `date` property are to restrict your field
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
`collections/ExampleCollection.ts`
|
||||
|
||||
```js
|
||||
{
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -74,6 +76,5 @@ Common use cases for customizing the `date` property are to restrict your field
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
};
|
||||
```
|
||||
|
||||
@@ -44,9 +44,11 @@ Set this property to a string that will be used for browser autocomplete.
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -56,6 +58,5 @@ Set this property to a string that will be used for browser autocomplete.
|
||||
required: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
};
|
||||
```
|
||||
|
||||
@@ -39,9 +39,11 @@ Set this property to `true` to hide this field's gutter within the admin panel.
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -65,6 +67,5 @@ Set this property to `true` to hide this field's gutter within the admin panel.
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
};
|
||||
```
|
||||
|
||||
@@ -50,9 +50,11 @@ Set this property to a string that will be used for browser autocomplete.
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -64,6 +66,5 @@ Set this property to a string that will be used for browser autocomplete.
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
};
|
||||
```
|
||||
|
||||
@@ -16,8 +16,10 @@ Fields are defined as an array on Collections and Globals via the `fields` key.
|
||||
The required `type` property on a field determines what values it can accept, how it is presented in the API, and how the field will be rendered in the admin interface.
|
||||
|
||||
**Simple collection with two fields:**
|
||||
```js
|
||||
const Pages = {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Page: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
fields: [
|
||||
{
|
||||
@@ -29,7 +31,7 @@ const Pages = {
|
||||
type: 'checkbox', // highlight-line
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Field types
|
||||
@@ -80,8 +82,10 @@ There are two arguments available to custom validation functions.
|
||||
| `payload` | If the `validate` function is being executed on the server, Payload will be exposed for easily running local operations. |
|
||||
|
||||
Example:
|
||||
```js
|
||||
{
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Orders: CollectionConfig = {
|
||||
slug: 'orders',
|
||||
fields: [
|
||||
{
|
||||
@@ -101,27 +105,27 @@ Example:
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
When supplying a field `validate` function, Payload will use yours in place of the default. To make use of the default field validation in your custom logic you can import, call and return the result as needed.
|
||||
|
||||
For example:
|
||||
```js
|
||||
```ts
|
||||
import { text } from 'payload/fields/validations';
|
||||
const field =
|
||||
{
|
||||
name: 'notBad',
|
||||
type: 'text',
|
||||
validate: (val, args) => {
|
||||
if (value === 'bad') {
|
||||
return 'This cannot be "bad"';
|
||||
}
|
||||
// highlight-start
|
||||
return text(val, args);
|
||||
// highlight-end
|
||||
},
|
||||
}
|
||||
|
||||
const field: Field = {
|
||||
name: 'notBad',
|
||||
type: 'text',
|
||||
validate: (val, args) => {
|
||||
if (val === 'bad') {
|
||||
return 'This cannot be "bad"';
|
||||
}
|
||||
// highlight-start
|
||||
return text(val, args);
|
||||
// highlight-end
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Customizable ID
|
||||
@@ -131,7 +135,7 @@ Users are then required to provide a custom ID value when creating a record thro
|
||||
Valid ID types are `number` and `text`.
|
||||
|
||||
Example:
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
@@ -174,7 +178,7 @@ The `condition` function should return a boolean that will control if the field
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
@@ -212,21 +216,19 @@ Functions are called with an optional argument object containing:
|
||||
|
||||
Here is an example of a defaultValue function that uses both:
|
||||
|
||||
```js
|
||||
```ts
|
||||
const translation: {
|
||||
en: 'Written by',
|
||||
es: 'Escrito por',
|
||||
en: 'Written by',
|
||||
es: 'Escrito por',
|
||||
};
|
||||
|
||||
const field = {
|
||||
name: 'attribution',
|
||||
type: 'text',
|
||||
admin: {
|
||||
// highlight-start
|
||||
defaultValue: ({ user, locale }) => (`${translation[locale]} ${user.name}`)
|
||||
// highlight-end
|
||||
}
|
||||
};
|
||||
name: 'attribution',
|
||||
type: 'text',
|
||||
// highlight-start
|
||||
defaultValue: ({ user, locale }) => (`${translation[locale]} ${user.name}`)
|
||||
// highlight-end
|
||||
};
|
||||
```
|
||||
|
||||
<Banner type="success">
|
||||
@@ -244,7 +246,7 @@ As shown above, you can simply provide a string that will show by the field, but
|
||||
|
||||
**Function Example:**
|
||||
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
@@ -262,7 +264,7 @@ As shown above, you can simply provide a string that will show by the field, but
|
||||
This example will display the number of characters allowed as the user types.
|
||||
|
||||
**Component Example:**
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
fields: [
|
||||
{
|
||||
@@ -289,10 +291,8 @@ This component will count the number of characters entered.
|
||||
|
||||
You can import the internal Payload `Field` type as well as other common field types as follows:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import type {
|
||||
Field,
|
||||
Validate,
|
||||
Condition,
|
||||
} from 'payload/types';
|
||||
```
|
||||
|
||||
@@ -35,9 +35,11 @@ The data structure in the database matches the GeoJSON structure to represent po
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -46,7 +48,7 @@ The data structure in the database matches the GeoJSON structure to represent po
|
||||
label: 'Location',
|
||||
},
|
||||
]
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Querying
|
||||
|
||||
@@ -45,9 +45,11 @@ The `layout` property allows for the radio group to be styled as a horizonally o
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -45,6 +45,14 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma
|
||||
The <a href="/docs/getting-started/concepts#depth">Depth</a> parameter can be used to automatically populate related documents that are returned by the API.
|
||||
</Banner>
|
||||
|
||||
### Admin config
|
||||
|
||||
In addition to the default [field admin config](/docs/fields/overview#admin-config), the Relationship field type also allows for the following admin-specific properties:
|
||||
|
||||
**`isSortable`**
|
||||
|
||||
Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop. (Only works when `hasMany` is set to `true`)
|
||||
|
||||
### Filtering relationship options
|
||||
|
||||
Options can be dynamically limited by supplying a [query constraint](/docs/queries/overview), which will be used both for validating input and filtering available relationships in the UI.
|
||||
@@ -61,26 +69,26 @@ The `filterOptions` property can either be a `Where` query directly, or a functi
|
||||
|
||||
**Example:**
|
||||
|
||||
```js
|
||||
const relationshipField = {
|
||||
name: 'purchase',
|
||||
type: 'relationship',
|
||||
relationTo: ['products', 'services'],
|
||||
filterOptions: ({ relationTo, siblingData }) => {
|
||||
// returns a Where query dynamically by the type of relationship
|
||||
if (relationTo === 'products') {
|
||||
return {
|
||||
'stock': { is_greater_than: siblingData.quantity }
|
||||
}
|
||||
```ts
|
||||
const relationshipField = {
|
||||
name: 'purchase',
|
||||
type: 'relationship',
|
||||
relationTo: ['products', 'services'],
|
||||
filterOptions: ({ relationTo, siblingData }) => {
|
||||
// returns a Where query dynamically by the type of relationship
|
||||
if (relationTo === 'products') {
|
||||
return {
|
||||
'stock': { greater_than: siblingData.quantity }
|
||||
}
|
||||
}
|
||||
|
||||
if (relationTo === 'services') {
|
||||
return {
|
||||
'isAvailable': { equals: true }
|
||||
}
|
||||
if (relationTo === 'services') {
|
||||
return {
|
||||
'isAvailable': { equals: true }
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
You can learn more about writing queries [here](/docs/queries/overview).
|
||||
@@ -98,7 +106,7 @@ Given the variety of options possible within the `relationship` field type, the
|
||||
|
||||
The most simple pattern of a relationship is to use `hasMany: false` with a `relationTo` that allows for only one type of collection.
|
||||
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
@@ -129,7 +137,7 @@ When querying documents in this collection via REST API, you could query as foll
|
||||
|
||||
Also known as **dynamic references**, in this configuration, the `relationTo` field is an array of Collection slugs that tells Payload which Collections are valid to reference.
|
||||
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
@@ -168,7 +176,7 @@ This query would return only documents that have an owner relationship to organi
|
||||
|
||||
The `hasMany` tells Payload that there may be more than one collection saved to the field.
|
||||
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
@@ -196,7 +204,7 @@ When querying documents, the format does not change for arrays:
|
||||
|
||||
#### Has Many - Polymorphic
|
||||
|
||||
```js
|
||||
```ts
|
||||
{
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
|
||||
@@ -77,6 +77,13 @@ The default `leaves` available in Payload are:
|
||||
|
||||
Set this property to `true` to hide this field's gutter within the admin panel. The field gutter is rendered as a vertical line and padding, but often if this field is nested within a Group, Block, or Array, you may want to hide the gutter.
|
||||
|
||||
**`link.fields`**
|
||||
|
||||
This allows [fields](/docs/fields/overview) to be saved as extra fields on a link inside the Rich Text Editor. When this is present, the fields will render inside a modal that can be opened by clicking the "edit" button on the link element.
|
||||
|
||||

|
||||
*RichText link with custom fields*
|
||||
|
||||
**`upload.collections[collection-name].fields`**
|
||||
|
||||
This allows [fields](/docs/fields/overview) to be saved as meta data on an upload field inside the Rich Text Editor. When this is present, the fields will render inside a modal that can be opened by clicking the "edit" button on the upload element.
|
||||
@@ -126,9 +133,11 @@ Custom `Leaf` objects follow a similar pattern but require you to define the `Le
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -178,7 +187,7 @@ Custom `Leaf` objects follow a similar pattern but require you to define the `Le
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
For more examples regarding how to define your own elements and leaves, check out the example [`RichText` field](https://github.com/payloadcms/public-demo/blob/master/src/fields/hero.ts) within the Public Demo source code.
|
||||
@@ -187,7 +196,7 @@ For more examples regarding how to define your own elements and leaves, check ou
|
||||
|
||||
As the Rich Text field saves its content in a JSON format, you'll need to render it as HTML yourself. Here is an example for how to generate JSX / HTML from Rich Text content:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import React, { Fragment } from 'react';
|
||||
import escapeHTML from 'escape-html';
|
||||
import { Text } from 'slate';
|
||||
@@ -308,7 +317,7 @@ If you want to utilize this functionality within your own custom elements, you c
|
||||
|
||||
`customLargeBodyElement.js`:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import Button from './Button';
|
||||
import Element from './Element';
|
||||
import withLargeBody from './plugin';
|
||||
@@ -338,7 +347,7 @@ The plugin itself extends Payload's built-in `shouldBreakOutOnEnter` Slate funct
|
||||
|
||||
If you are building your own custom Rich Text elements or leaves, you may benefit from importing the following types:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import type {
|
||||
RichTextCustomElement,
|
||||
RichTextCustomLeaf,
|
||||
|
||||
@@ -21,9 +21,11 @@ keywords: row, fields, config, configuration, documentation, Content Management
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -51,12 +51,17 @@ In addition to the default [field admin config](/docs/fields/overview#admin-conf
|
||||
|
||||
Set to `true` if you'd like this field to be clearable within the Admin UI.
|
||||
|
||||
**`isSortable`**
|
||||
|
||||
Set to `true` if you'd like this field to be sortable within the Admin UI using drag and drop. (Only works when `hasMany` is set to `true`)
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
```js
|
||||
{
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
@@ -65,6 +70,7 @@ Set to `true` if you'd like this field to be clearable within the Admin UI.
|
||||
hasMany: true,
|
||||
admin: {
|
||||
isClearable: true,
|
||||
isSortable: true, // use mouse to drag and drop different values, and sort them according to your choice
|
||||
},
|
||||
options: [
|
||||
{
|
||||
|
||||
@@ -35,9 +35,11 @@ Each tab has its own required `label` and `fields` array. You can also optionall
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -46,9 +46,11 @@ Set this property to a string that will be used for browser autocomplete.
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -46,9 +46,11 @@ Set this property to a string that will be used for browser autocomplete.
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -34,9 +34,11 @@ With this field, you can also inject custom `Cell` components that appear as add
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -46,9 +46,11 @@ keywords: upload, images media, fields, config, configuration, documentation, Co
|
||||
|
||||
### Example
|
||||
|
||||
`collections/ExampleCollection.js`
|
||||
```js
|
||||
{
|
||||
`collections/ExampleCollection.ts`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
slug: 'example-collection',
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ By default, the Payload config lives in the root folder of your code and is name
|
||||
A Collection represents a type of content that Payload will store and can contain many documents.
|
||||
</Banner>
|
||||
|
||||
Collections define the shape of your data as well as all functionalities attached to that data. They will contain or many "documents", all corresponding with the same fields and functionalities that you define.
|
||||
Collections define the shape of your data as well as all functionalities attached to that data. They will contain one or many "documents", all corresponding with the same fields and functionalities that you define.
|
||||
|
||||
They can represent anything you can store in a database - for example - pages, posts, users, people, orders, categories, events, customers, transactions, and anything else your app needs.
|
||||
|
||||
@@ -70,7 +70,7 @@ For more, visit the [Access Control documentation](/docs/access-control/overview
|
||||
|
||||
You can specify population `depth` via query parameter in the REST API and by an option in the local API. *Depth has no effect in the GraphQL API, because there, depth is based on the shape of your queries.*
|
||||
It is also possible to limit the depth for specific `relation` and `upload` fields using the `maxDepth` property in your configuration.
|
||||
**For example, let's look the following Collections:** `departments`, `users`, `posts`
|
||||
**For example, let's look at the following Collections:** `departments`, `users`, `posts`
|
||||
|
||||
```
|
||||
// type: 'relationship' fields are equal to 1 depth level
|
||||
|
||||
@@ -33,7 +33,7 @@ Both `graphQL.queries` and `graphQL.mutations` functions should return an object
|
||||
|
||||
`payload.config.js`:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config';
|
||||
import myCustomQueryResolver from './graphQL/resolvers/myCustomQueryResolver';
|
||||
|
||||
|
||||
@@ -22,15 +22,17 @@ At the top of your Payload config you can define all the options to manage Graph
|
||||
| `queries` | Any custom Queries to be added in addition to what Payload provides. [More](/docs/graphql/extending) |
|
||||
| `maxComplexity` | A number used to set the maximum allowed complexity allowed by requests [More](/docs/graphql/overview#query-complexity-limits) |
|
||||
| `disablePlaygroundInProduction` | A boolean that if false will enable the graphQL playground, defaults to true. [More](/docs/graphql/overview#graphql-playground) |
|
||||
| `disable` | A boolean that if false will disable the graphQL entirely, defaults to false. |
|
||||
| `disable` | A boolean that if true will disable the graphQL entirely, defaults to false. |
|
||||
| `schemaOutputFile` | A string for the file path used by the generate schema command. Defaults to `graphql.schema` next to `payload.config.ts` [More](/docs/graphql/graphql-schema) |
|
||||
|
||||
## Collections
|
||||
|
||||
Everything that can be done to a Collection via the REST or Local API can be done with GraphQL (outside of uploading files, which is REST-only). If you have a collection as follows:
|
||||
|
||||
```js
|
||||
const PublicUser = {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const PublicUser: CollectionConfig = {
|
||||
slug: 'public-users',
|
||||
auth: true, // Auth is enabled
|
||||
labels: {
|
||||
@@ -70,8 +72,10 @@ const PublicUser = {
|
||||
|
||||
Globals are also fully supported. For example:
|
||||
|
||||
```js
|
||||
const Header = {
|
||||
```ts
|
||||
import { GlobalConfig } from 'payload/types';
|
||||
|
||||
const Header: GlobalConfig = {
|
||||
slug: 'header',
|
||||
fields: [
|
||||
...
|
||||
|
||||
@@ -30,10 +30,11 @@ 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/example-hooks.js`
|
||||
```js
|
||||
// Collection config
|
||||
module.exports = {
|
||||
`collections/exampleHooks.js`
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleHooks: CollectionConfig = {
|
||||
slug: 'example-hooks',
|
||||
fields: [
|
||||
{ name: 'name', type: 'text'},
|
||||
@@ -65,8 +66,10 @@ The `beforeOperation` Hook type can be used to modify the arguments that operati
|
||||
|
||||
Available Collection operations include `create`, `read`, `update`, `delete`, `login`, `refresh` and `forgotPassword`.
|
||||
|
||||
```js
|
||||
const beforeOperationHook = async ({
|
||||
```ts
|
||||
import { CollectionBeforeOperationHook } from 'payload/types';
|
||||
|
||||
const beforeOperationHook: CollectionBeforeOperationHook = async ({
|
||||
args, // Original arguments passed into the operation
|
||||
operation, // name of the operation
|
||||
}) => {
|
||||
@@ -78,8 +81,10 @@ const beforeOperationHook = async ({
|
||||
|
||||
Runs before the `create` and `update` operations. This hook allows you to add or format data before the incoming data is validated.
|
||||
|
||||
```js
|
||||
const beforeValidateHook = async ({
|
||||
```ts
|
||||
import { CollectionBeforeOperationHook } from 'payload/types';
|
||||
|
||||
const beforeValidateHook: CollectionBeforeValidateHook = async ({
|
||||
data, // incoming data to update or create with
|
||||
req, // full express request
|
||||
operation, // name of the operation ie. 'create', 'update'
|
||||
@@ -93,8 +98,10 @@ const beforeValidateHook = 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.
|
||||
|
||||
```js
|
||||
const beforeChangeHook = async ({
|
||||
```ts
|
||||
import { CollectionBeforeChangeHook } from 'payload/types';
|
||||
|
||||
const beforeChangeHook: CollectionBeforeChangeHook = async ({
|
||||
data, // incoming data to update or create with
|
||||
req, // full express request
|
||||
operation, // name of the operation ie. 'create', 'update'
|
||||
@@ -108,8 +115,10 @@ const beforeChangeHook = 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.
|
||||
|
||||
```js
|
||||
const afterChangeHook = async ({
|
||||
```ts
|
||||
import { CollectionAfterChangeHook } from 'payload/types';
|
||||
|
||||
const afterChangeHook: CollectionAfterChangeHook = async ({
|
||||
doc, // full document data
|
||||
req, // full express request
|
||||
operation, // name of the operation ie. 'create', 'update'
|
||||
@@ -122,8 +131,10 @@ const afterChangeHook = 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.
|
||||
|
||||
```js
|
||||
const beforeReadHook = async ({
|
||||
```ts
|
||||
import { CollectionBeforeReadHook } from 'payload/types';
|
||||
|
||||
const beforeReadHook: CollectionBeforeReadHook = async ({
|
||||
doc, // full document data
|
||||
req, // full express request
|
||||
query, // JSON formatted query
|
||||
@@ -136,8 +147,10 @@ const beforeReadHook = 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.
|
||||
|
||||
```js
|
||||
const afterReadHook = async ({
|
||||
```ts
|
||||
import { CollectionAfterReadHook } from 'payload/types';
|
||||
|
||||
const afterReadHook: CollectionAfterReadHook = async ({
|
||||
doc, // full document data
|
||||
req, // full express request
|
||||
query, // JSON formatted query
|
||||
@@ -151,8 +164,10 @@ const afterReadHook = async ({
|
||||
|
||||
Runs before the `delete` operation. Returned values are discarded.
|
||||
|
||||
```js
|
||||
const beforeDeleteHook = async ({
|
||||
```ts
|
||||
import { CollectionBeforeDeleteHook } from 'payload/types';
|
||||
|
||||
const beforeDeleteHook: CollectionBeforeDeleteHook = async ({
|
||||
req, // full express request
|
||||
id, // id of document to delete
|
||||
}) => {...}
|
||||
@@ -162,8 +177,10 @@ const beforeDeleteHook = async ({
|
||||
|
||||
Runs immediately after the `delete` operation removes records from the database. Returned values are discarded.
|
||||
|
||||
```js
|
||||
const afterDeleteHook = async ({
|
||||
```ts
|
||||
import { CollectionAfterDeleteHook } from 'payload/types';
|
||||
|
||||
const afterDeleteHook: CollectionAfterDeleteHook = async ({
|
||||
req, // full express request
|
||||
id, // id of document to delete
|
||||
doc, // deleted document
|
||||
@@ -174,8 +191,10 @@ const afterDeleteHook = async ({
|
||||
|
||||
For auth-enabled Collections, this hook runs after successful `login` operations. You can optionally modify the user that is returned.
|
||||
|
||||
```js
|
||||
const beforeLoginHook = async ({
|
||||
```ts
|
||||
import { CollectionBeforeLoginHook } from 'payload/types';
|
||||
|
||||
const beforeLoginHook: CollectionBeforeLoginHook = async ({
|
||||
req, // full express request
|
||||
user, // user being logged in
|
||||
token, // user token
|
||||
@@ -188,8 +207,10 @@ const beforeLoginHook = async ({
|
||||
|
||||
For auth-enabled Collections, this hook runs after successful `login` operations. You can optionally modify the user that is returned.
|
||||
|
||||
```js
|
||||
const afterLoginHook = async ({
|
||||
```ts
|
||||
import { CollectionAfterLoginHook } from 'payload/types';
|
||||
|
||||
const afterLoginHook: CollectionAfterLoginHook = async ({
|
||||
req, // full express request
|
||||
}) => {...}
|
||||
```
|
||||
@@ -198,8 +219,10 @@ const afterLoginHook = async ({
|
||||
|
||||
For auth-enabled Collections, this hook runs after `logout` operations.
|
||||
|
||||
```js
|
||||
const afterLogoutHook = async ({
|
||||
```ts
|
||||
import { CollectionAfterLogoutHook } from 'payload/types';
|
||||
|
||||
const afterLogoutHook: CollectionAfterLogoutHook = async ({
|
||||
req, // full express request
|
||||
}) => {...}
|
||||
```
|
||||
@@ -208,8 +231,10 @@ const afterLogoutHook = async ({
|
||||
|
||||
For auth-enabled Collections, this hook runs after `refresh` operations.
|
||||
|
||||
```js
|
||||
const afterRefreshHook = async ({
|
||||
```ts
|
||||
import { CollectionAfterRefreshHook } from 'payload/types';
|
||||
|
||||
const afterRefreshHook: CollectionAfterRefreshHook = async ({
|
||||
req, // full express request
|
||||
res, // full express response
|
||||
token, // newly refreshed user token
|
||||
@@ -220,8 +245,10 @@ const afterRefreshHook = async ({
|
||||
|
||||
For auth-enabled Collections, this hook runs after `me` operations.
|
||||
|
||||
```js
|
||||
const afterMeHook = async ({
|
||||
```ts
|
||||
import { CollectionAfterMeHook } from 'payload/types';
|
||||
|
||||
const afterMeHook: CollectionAfterMeHook = async ({
|
||||
req, // full express request
|
||||
response, // response to return
|
||||
}) => {...}
|
||||
@@ -231,8 +258,10 @@ const afterMeHook = async ({
|
||||
|
||||
For auth-enabled Collections, this hook runs after successful `forgotPassword` operations. Returned values are discarded.
|
||||
|
||||
```js
|
||||
const afterLoginHook = async ({
|
||||
```ts
|
||||
import { CollectionAfterForgotPasswordHook } from 'payload/types';
|
||||
|
||||
const afterLoginHook: CollectionAfterForgotPasswordHook = async ({
|
||||
req, // full express request
|
||||
user, // user being logged in
|
||||
token, // user token
|
||||
@@ -245,7 +274,7 @@ const afterLoginHook = async ({
|
||||
|
||||
Payload exports a type for each Collection hook which can be accessed as follows:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import type {
|
||||
CollectionBeforeOperationHook,
|
||||
CollectionBeforeValidateHook,
|
||||
@@ -262,7 +291,4 @@ import type {
|
||||
CollectionAfterMeHook,
|
||||
CollectionAfterForgotPasswordHook,
|
||||
} from 'payload/types';
|
||||
|
||||
// Use hook types here...
|
||||
}
|
||||
```
|
||||
|
||||
@@ -26,8 +26,10 @@ Field-level hooks offer incredible potential for encapsulating your logic. They
|
||||
## Config
|
||||
|
||||
Example field configuration:
|
||||
```js
|
||||
{
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const ExampleCollection: CollectionConfig = {
|
||||
name: 'name',
|
||||
type: 'text',
|
||||
// highlight-start
|
||||
@@ -77,7 +79,7 @@ All field hooks can optionally modify the return value of the field before the o
|
||||
|
||||
Payload exports a type for field hooks which can be accessed and used as follows:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import type { FieldHook } from 'payload/types';
|
||||
|
||||
// Field hook type is a generic that takes three arguments:
|
||||
|
||||
@@ -19,9 +19,10 @@ Globals feature the ability to define the following hooks:
|
||||
All Global Hook properties accept arrays of synchronous or asynchronous functions. Each Hook type receives specific arguments and has the ability to modify specific outputs.
|
||||
|
||||
`globals/example-hooks.js`
|
||||
```js
|
||||
// Global config
|
||||
module.exports = {
|
||||
```ts
|
||||
import { GlobalConfig } from 'payload/types';
|
||||
|
||||
const ExampleHooks: GlobalConfig = {
|
||||
slug: 'header',
|
||||
fields: [
|
||||
{ name: 'title', type: 'text'},
|
||||
@@ -40,8 +41,10 @@ module.exports = {
|
||||
|
||||
Runs before the `update` operation. This hook allows you to add or format data before the incoming data is validated.
|
||||
|
||||
```js
|
||||
const beforeValidateHook = async ({
|
||||
```ts
|
||||
import { GlobalBeforeValidateHook } from 'payload/types'
|
||||
|
||||
const beforeValidateHook: GlobalBeforeValidateHook = async ({
|
||||
data, // incoming data to update or create with
|
||||
req, // full express request
|
||||
originalDoc, // original document
|
||||
@@ -54,8 +57,10 @@ const beforeValidateHook = async ({
|
||||
|
||||
Immediately following validation, `beforeChange` hooks will run within the `update` operation. 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.
|
||||
|
||||
```js
|
||||
const beforeChangeHook = async ({
|
||||
```ts
|
||||
import { GlobalBeforeChangeHook } from 'payload/types'
|
||||
|
||||
const beforeChangeHook: GlobalBeforeChangeHook = async ({
|
||||
data, // incoming data to update or create with
|
||||
req, // full express request
|
||||
originalDoc, // original document
|
||||
@@ -68,8 +73,10 @@ const beforeChangeHook = async ({
|
||||
|
||||
After a global is updated, the `afterChange` hook runs. Use this hook to purge caches of your applications, sync site data to CRMs, and more.
|
||||
|
||||
```js
|
||||
const afterChangeHook = async ({
|
||||
```ts
|
||||
import { GlobalAfterChangeHook } from 'payload/types'
|
||||
|
||||
const afterChangeHook: GlobalAfterChangeHook = async ({
|
||||
doc, // full document data
|
||||
req, // full express request
|
||||
}) => {
|
||||
@@ -81,8 +88,10 @@ const afterChangeHook = async ({
|
||||
|
||||
Runs before `findOne` global operation is 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.
|
||||
|
||||
```js
|
||||
const beforeReadHook = async ({
|
||||
```ts
|
||||
import { GlobalBeforeReadHook } from 'payload/types'
|
||||
|
||||
const beforeReadHook: GlobalBeforeReadHook = async ({
|
||||
doc, // full document data
|
||||
req, // full express request
|
||||
}) => {...}
|
||||
@@ -92,8 +101,10 @@ const beforeReadHook = async ({
|
||||
|
||||
Runs as the last step before a global is returned. Flattens locales, hides protected fields, and removes fields that users do not have access to.
|
||||
|
||||
```js
|
||||
const afterReadHook = async ({
|
||||
```ts
|
||||
import { GlobalAfterReadHook } from 'payload/types'
|
||||
|
||||
const afterReadHook: GlobalAfterReadHook = async ({
|
||||
doc, // full document data
|
||||
req, // full express request
|
||||
findMany, // boolean to denote if this hook is running against finding one, or finding many (useful in versions)
|
||||
@@ -104,7 +115,7 @@ const afterReadHook = async ({
|
||||
|
||||
Payload exports a type for each Global hook which can be accessed as follows:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import type {
|
||||
GlobalBeforeValidateHook,
|
||||
GlobalBeforeChangeHook,
|
||||
@@ -112,7 +123,4 @@ import type {
|
||||
GlobalBeforeReadHook,
|
||||
GlobalAfterReadHook,
|
||||
} from 'payload/types';
|
||||
|
||||
// Use hook types here...
|
||||
}
|
||||
```
|
||||
|
||||
@@ -28,10 +28,11 @@ You can gain access to the currently running `payload` object via two ways:
|
||||
You can import or require `payload` into your own files after it's been initialized, but you need to make sure that your `import` / `require` statements come **after** you call `payload.init()`—otherwise Payload won't have been initialized yet. That might be obvious. To us, it's usually not.
|
||||
|
||||
Example:
|
||||
```js
|
||||
```ts
|
||||
import payload from 'payload';
|
||||
import { CollectionAfterChangeHook } from 'payload/types';
|
||||
|
||||
const afterChangeHook = async () => {
|
||||
const afterChangeHook: CollectionAfterChangeHook = async () => {
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
});
|
||||
@@ -43,8 +44,8 @@ const afterChangeHook = async () => {
|
||||
Payload is available anywhere you have access to the Express `req` - including within your access control and hook functions.
|
||||
|
||||
Example:
|
||||
```js
|
||||
const afterChangeHook = async ({ req: { payload }}) => {
|
||||
```ts
|
||||
const afterChangeHook: CollectionAfterChangeHook = async ({ req: { payload }}) => {
|
||||
const posts = await payload.find({
|
||||
collection: 'posts',
|
||||
});
|
||||
@@ -319,3 +320,25 @@ const result = await payload.updateGlobal({
|
||||
showHiddenFields: true,
|
||||
})
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
Local API calls also support passing in a generic. This is especially useful if you generate your TS types using a [generate types script](/docs/typescript/generate-types).
|
||||
|
||||
Here is an example of usage:
|
||||
|
||||
```ts
|
||||
// Our generated types
|
||||
import { Post } from './payload-types'
|
||||
|
||||
// Add Post types as generic to create function
|
||||
const post: Post = await payload.create<Post>({
|
||||
collection: 'posts',
|
||||
|
||||
// Data will now be typed as Post and give you type hints
|
||||
data: {
|
||||
title: 'my title',
|
||||
description: 'my description',
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
@@ -83,11 +83,10 @@ After all plugins are executed, the full config with all plugins will be sanitiz
|
||||
|
||||
Here is an example for how to automatically add a `lastModifiedBy` field to all Payload collections using a Plugin written in TypeScript.
|
||||
|
||||
```js
|
||||
import { Config } from 'payload/config';
|
||||
import { CollectionConfig } from 'payload/dist/collections/config/types';
|
||||
```ts
|
||||
import { Config, Plugin } from 'payload/config';
|
||||
|
||||
const addLastModified = (incomingConfig: Config): Config => {
|
||||
const addLastModified: Plugin = (incomingConfig: Config): Config => {
|
||||
// Find all incoming auth-enabled collections
|
||||
// so we can create a lastModifiedBy relationship field
|
||||
// to all auth collections
|
||||
@@ -137,6 +136,6 @@ export default addLastModified;
|
||||
|
||||
#### Available Plugins
|
||||
|
||||
You can discover existing plugins by browsing the `payload-plugin` topic on [Github](https://github.com/topics/payload-plugin).
|
||||
You can discover existing plugins by browsing the `payload-plugin` topic on [GitHub](https://github.com/topics/payload-plugin).
|
||||
|
||||
For maintainers building plugins for others to use, please add the topic to help others find it. If you would like one to be built by the core Payload team, [open a Feature Request](https://github.com/payloadcms/payload/discussions) in our GitHub Discussions board. We would be happy to review your code and maybe feature you and your plugin where appropriate.
|
||||
|
||||
@@ -37,7 +37,7 @@ Because _**you**_ are in complete control of who can do what with your data, you
|
||||
Before running in Production, you need to have built a production-ready copy of the Payload Admin panel. To do this, Payload provides the `build` NPM script. You can use it by adding a `script` to your `package.json` file like this:
|
||||
|
||||
`package.json`:
|
||||
```js
|
||||
```json
|
||||
{
|
||||
"name": "project-name-here",
|
||||
"scripts": {
|
||||
|
||||
@@ -16,8 +16,10 @@ Payload provides an extremely granular querying language through all APIs. Each
|
||||
|
||||
For example, say you have a collection as follows:
|
||||
|
||||
```js
|
||||
const Post = {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Post: CollectionConfig = {
|
||||
slug: 'posts',
|
||||
fields: [
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ All collection `find` queries are paginated automatically. Responses are returne
|
||||
| nextPage | `number` of next page, `null` if it doesn't exist |
|
||||
|
||||
**Example response:**
|
||||
```js
|
||||
```json
|
||||
{
|
||||
// Document Array // highlight-line
|
||||
"docs": [
|
||||
|
||||
@@ -29,7 +29,7 @@ Each collection is mounted using its `slug` value. For example, if a collection'
|
||||
| `GET` | `/api/{collectionSlug}` | Find paginated documents |
|
||||
| `GET` | `/api/{collectionSlug}/:id` | Find a specific document by ID |
|
||||
| `POST` | `/api/{collectionSlug}` | Create a new document |
|
||||
| `PUT` | `/api/{collectionSlug}/:id` | Update a document by ID |
|
||||
| `PATCH` | `/api/{collectionSlug}/:id` | Update a document by ID |
|
||||
| `DELETE` | `/api/{collectionSlug}/:id` | Delete an existing document by ID |
|
||||
|
||||
##### Additional `find` query parameters
|
||||
@@ -77,7 +77,7 @@ In addition to the dynamically generated endpoints above Payload also has REST e
|
||||
|
||||
## Custom Endpoints
|
||||
|
||||
Additional REST API endpoints can be added to `collections` and `globals` by providing array of `endpoints` in the configuration. These can be used to write additional middleware on existing routes or build custom functionality into Payload apps and plugins.
|
||||
Additional REST API endpoints can be added to your application by providing an array of `endpoints` in various places within a Payload config. Custom endpoints are useful for adding additional middleware on existing routes or for building custom functionality into Payload apps and plugins. Endpoints can be added at the top of the Payload config, `collections`, and `globals` and accessed respective of the api and slugs you have configured.
|
||||
|
||||
Each endpoint object needs to have:
|
||||
|
||||
@@ -89,10 +89,11 @@ Each endpoint object needs to have:
|
||||
|
||||
Example:
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
// a collection of 'orders' with an additional route for tracking details, reachable at /api/orders/:id/tracking
|
||||
const Orders = {
|
||||
const Orders: CollectionConfig = {
|
||||
slug: 'orders',
|
||||
fields: [ /* ... */ ],
|
||||
// highlight-start
|
||||
|
||||
@@ -55,38 +55,40 @@ _An asterisk denotes that a property above is required._
|
||||
|
||||
**Example Upload collection:**
|
||||
|
||||
```js
|
||||
const Media = {
|
||||
slug: "media",
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
upload: {
|
||||
staticURL: "/media",
|
||||
staticDir: "media",
|
||||
staticURL: '/media',
|
||||
staticDir: 'media',
|
||||
imageSizes: [
|
||||
{
|
||||
name: "thumbnail",
|
||||
name: 'thumbnail',
|
||||
width: 400,
|
||||
height: 300,
|
||||
crop: "centre",
|
||||
crop: 'centre',
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
name: 'card',
|
||||
width: 768,
|
||||
height: 1024,
|
||||
crop: "centre",
|
||||
crop: 'centre',
|
||||
},
|
||||
{
|
||||
name: "tablet",
|
||||
name: 'tablet',
|
||||
width: 1024,
|
||||
// By specifying `null` or leaving a height undefined,
|
||||
// the image will be sized to a certain width,
|
||||
// but it will retain its original aspect ratio
|
||||
// and calculate a height automatically.
|
||||
height: null,
|
||||
crop: "centre",
|
||||
crop: 'centre',
|
||||
},
|
||||
],
|
||||
adminThumbnail: "thumbnail",
|
||||
mimeTypes: ["image/*"],
|
||||
adminThumbnail: 'thumbnail',
|
||||
mimeTypes: ['image/*'],
|
||||
},
|
||||
};
|
||||
```
|
||||
@@ -97,17 +99,17 @@ Payload relies on the [`express-fileupload`](https://www.npmjs.com/package/expre
|
||||
|
||||
A common example of what you might want to customize within Payload-wide Upload options would be to increase the allowed `fileSize` of uploads sent to Payload:
|
||||
|
||||
```js
|
||||
import { buildConfig } from "payload/config";
|
||||
```ts
|
||||
import { buildConfig } from 'payload/config';
|
||||
|
||||
export default buildConfig({
|
||||
collections: [
|
||||
{
|
||||
slug: "media",
|
||||
slug: 'media',
|
||||
fields: [
|
||||
{
|
||||
name: "alt",
|
||||
type: "text",
|
||||
name: 'alt',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
upload: true,
|
||||
@@ -158,12 +160,14 @@ You can specify how Payload retrieves admin thumbnails for your upload-enabled C
|
||||
|
||||
**Example custom Admin thumbnail:**
|
||||
|
||||
```js
|
||||
const Media = {
|
||||
slug: "media",
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
upload: {
|
||||
staticURL: "/media",
|
||||
staticDir: "media",
|
||||
staticURL: '/media',
|
||||
staticDir: 'media',
|
||||
imageSizes: [
|
||||
// ... image sizes here
|
||||
],
|
||||
@@ -191,13 +195,15 @@ Some example values are: `image/*`, `audio/*`, `video/*`, `image/png`, `applicat
|
||||
|
||||
**Example mimeTypes usage:**
|
||||
|
||||
```js
|
||||
const Media = {
|
||||
slug: "media",
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
upload: {
|
||||
staticURL: "/media",
|
||||
staticDir: "media",
|
||||
mimeTypes: ["image/*", "application/pdf"], // highlight-line
|
||||
staticURL: '/media',
|
||||
staticDir: 'media',
|
||||
mimeTypes: ['image/*', 'application/pdf'], // highlight-line
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
@@ -25,8 +25,10 @@ Collections and Globals both support the same options for configuring autosave.
|
||||
|
||||
**Example config with versions, drafts, and autosave enabled:**
|
||||
|
||||
```js
|
||||
const Pages = {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
|
||||
@@ -81,8 +81,10 @@ You can use the `read` [Access Control](/docs/access-control/collections#read) m
|
||||
|
||||
Here is an example that utilizes the `_status` field to require a user to be logged in to retrieve drafts:
|
||||
|
||||
```js
|
||||
const Pages = {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
@@ -114,8 +116,10 @@ const Pages = {
|
||||
|
||||
Here is an example for how to write an access control function that grants access to both documents where `_status` is equal to "published" and where `_status` does not exist:
|
||||
|
||||
```js
|
||||
const Pages = {
|
||||
```ts
|
||||
import { CollectionConfig } from 'payload/types';
|
||||
|
||||
const Pages: CollectionConfig = {
|
||||
slug: 'pages',
|
||||
access: {
|
||||
read: ({ req }) => {
|
||||
|
||||
@@ -77,7 +77,7 @@ _slug_versions
|
||||
|
||||
Each document in this new `versions` collection will store a set of meta properties about the version as well as a _full_ copy of the document. For example, a version's data might look like this for a Collection document:
|
||||
|
||||
```js
|
||||
```json
|
||||
{
|
||||
"_id": "61cf752c19cdf1b1af7b61f1", // a unique ID of this version
|
||||
"parent": "61ce1354091d5b3ffc20ea6e", // the ID of the parent document
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "payload",
|
||||
"version": "1.0.20",
|
||||
"version": "1.0.36",
|
||||
"description": "Node, React and MongoDB Headless CMS and Application Framework",
|
||||
"license": "MIT",
|
||||
"author": {
|
||||
@@ -85,7 +85,7 @@
|
||||
"@babel/preset-typescript": "^7.12.1",
|
||||
"@babel/register": "^7.11.5",
|
||||
"@date-io/date-fns": "^2.10.6",
|
||||
"@faceless-ui/modal": "^1.1.7",
|
||||
"@faceless-ui/modal": "^2.0.0-alpha.4",
|
||||
"@faceless-ui/scroll-info": "^1.2.3",
|
||||
"@faceless-ui/window-info": "^2.0.2",
|
||||
"@types/is-plain-object": "^2.0.4",
|
||||
@@ -107,7 +107,6 @@
|
||||
"express-fileupload": "1.4.0",
|
||||
"express-graphql": "0.12.0",
|
||||
"express-rate-limit": "^5.1.3",
|
||||
"falsey": "^1.0.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"find-up": "4.1.0",
|
||||
"flatley": "^5.2.0",
|
||||
@@ -163,9 +162,10 @@
|
||||
"react-dom": "^18.0.0",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-router-navigation-prompt": "^1.8.11",
|
||||
"react-router-navigation-prompt": "^1.9.6",
|
||||
"react-select": "^3.0.8",
|
||||
"react-simple-code-editor": "^0.11.0",
|
||||
"react-sortable-hoc": "^2.0.0",
|
||||
"react-toastify": "^8.2.0",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sass": "^1.52.1",
|
||||
@@ -271,7 +271,6 @@
|
||||
"get-port": "5.1.1",
|
||||
"glob": "^8.0.3",
|
||||
"graphql-request": "^3.4.0",
|
||||
"mongodb": "^3.6.2",
|
||||
"mongodb-memory-server": "^7.2.0",
|
||||
"nodemon": "^2.0.6",
|
||||
"passport-strategy": "^1.0.0",
|
||||
|
||||
@@ -34,6 +34,20 @@ export const requests = {
|
||||
return fetch(url, formattedOptions);
|
||||
},
|
||||
|
||||
patch: (url: string, options: RequestInit = { headers: {} }): Promise<Response> => {
|
||||
const headers = options && options.headers ? { ...options.headers } : {};
|
||||
|
||||
const formattedOptions = {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
},
|
||||
};
|
||||
|
||||
return fetch(url, formattedOptions);
|
||||
},
|
||||
|
||||
delete: (url: string, options: RequestInit = { headers: {} }): Promise<Response> => {
|
||||
const headers = options && options.headers ? { ...options.headers } : {};
|
||||
return fetch(url, {
|
||||
|
||||
@@ -208,6 +208,7 @@ const Routes = () => {
|
||||
if (permissions?.collections?.[collection.slug]?.read?.permission) {
|
||||
return (
|
||||
<DocumentInfoProvider
|
||||
key={`${collection.slug}-edit-${id}`}
|
||||
collection={collection}
|
||||
id={id}
|
||||
>
|
||||
|
||||
@@ -77,7 +77,7 @@ const Autosave: React.FC<Props> = ({ collection, global, id, publishedDocUpdated
|
||||
|
||||
if (collection && id) {
|
||||
url = `${serverURL}${api}/${collection.slug}/${id}?draft=true&autosave=true&locale=${locale}`;
|
||||
method = 'PUT';
|
||||
method = 'PATCH';
|
||||
}
|
||||
|
||||
if (global) {
|
||||
|
||||
@@ -31,6 +31,7 @@ const DateTime: React.FC<Props> = (props) => {
|
||||
if (dateTimeFormat === undefined) {
|
||||
if (pickerAppearance === 'dayAndTime') dateTimeFormat = 'MMM d, yyy h:mm a';
|
||||
else if (pickerAppearance === 'timeOnly') dateTimeFormat = 'h:mm a';
|
||||
else if (pickerAppearance === 'monthOnly') dateTimeFormat = 'MM/yyyy';
|
||||
else dateTimeFormat = 'MMM d, yyy';
|
||||
}
|
||||
|
||||
@@ -50,6 +51,7 @@ const DateTime: React.FC<Props> = (props) => {
|
||||
showPopperArrow: false,
|
||||
selected: value && new Date(value),
|
||||
customInputRef: 'ref',
|
||||
showMonthYearPicker: pickerAppearance === 'monthOnly',
|
||||
};
|
||||
|
||||
const classes = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
type SharedProps = {
|
||||
displayFormat?: string | undefined
|
||||
pickerAppearance?: 'dayAndTime' | 'timeOnly' | 'dayOnly'
|
||||
displayFormat?: string
|
||||
pickerAppearance?: 'dayAndTime' | 'timeOnly' | 'dayOnly' | 'monthOnly'
|
||||
}
|
||||
|
||||
type TimePickerProps = {
|
||||
@@ -16,6 +16,11 @@ type DayPickerProps = {
|
||||
maxDate?: Date
|
||||
}
|
||||
|
||||
type MonthPickerProps = {
|
||||
minDate?: Date
|
||||
maxDate?: Date
|
||||
}
|
||||
|
||||
export type ConditionalDateProps =
|
||||
| SharedProps & DayPickerProps & TimePickerProps & {
|
||||
pickerAppearance?: 'dayAndTime'
|
||||
@@ -26,6 +31,9 @@ export type ConditionalDateProps =
|
||||
| SharedProps & DayPickerProps & {
|
||||
pickerAppearance: 'dayOnly'
|
||||
}
|
||||
| SharedProps & MonthPickerProps & {
|
||||
pickerAppearance: 'monthOnly'
|
||||
}
|
||||
|
||||
export type Props = SharedProps & DayPickerProps & TimePickerProps & {
|
||||
value?: Date
|
||||
|
||||
@@ -33,7 +33,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
const { serverURL, routes: { api, admin } } = useConfig();
|
||||
const { setModified } = useForm();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { closeAll, toggle } = useModal();
|
||||
const { toggleModal } = useModal();
|
||||
const history = useHistory();
|
||||
const title = useTitle(useAsTitle) || id;
|
||||
const titleToRender = titleFromProps || title;
|
||||
@@ -55,12 +55,12 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
try {
|
||||
const json = await res.json();
|
||||
if (res.status < 400) {
|
||||
closeAll();
|
||||
toggleModal(modalSlug);
|
||||
toast.success(`${singular} "${title}" successfully deleted.`);
|
||||
return history.push(`${admin}/collections/${slug}`);
|
||||
}
|
||||
|
||||
closeAll();
|
||||
toggleModal(modalSlug);
|
||||
|
||||
if (json.errors) {
|
||||
json.errors.forEach((error) => toast.error(error.message));
|
||||
@@ -72,7 +72,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
return addDefaultError();
|
||||
}
|
||||
});
|
||||
}, [addDefaultError, closeAll, history, id, singular, slug, title, admin, api, serverURL, setModified]);
|
||||
}, [addDefaultError, toggleModal, modalSlug, history, id, singular, slug, title, admin, api, serverURL, setModified]);
|
||||
|
||||
if (id) {
|
||||
return (
|
||||
@@ -84,7 +84,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setDeleting(false);
|
||||
toggle(modalSlug);
|
||||
toggleModal(modalSlug);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
@@ -110,7 +110,7 @@ const DeleteDocument: React.FC<Props> = (props) => {
|
||||
id="confirm-cancel"
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={deleting ? undefined : () => toggle(modalSlug)}
|
||||
onClick={deleting ? undefined : () => toggleModal(modalSlug)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
.duplicate {
|
||||
|
||||
&__modal {
|
||||
@include blur-bg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
|
||||
.btn {
|
||||
margin-right: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
&__modal-template {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -1,39 +1,142 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Modal, useModal } from '@faceless-ui/modal';
|
||||
import { useConfig } from '../../utilities/Config';
|
||||
import { Props } from './types';
|
||||
import Button from '../Button';
|
||||
import { useForm } from '../../forms/Form/context';
|
||||
import { requests } from '../../../api';
|
||||
import { useForm, useFormModified } from '../../forms/Form/context';
|
||||
import MinimalTemplate from '../../templates/Minimal';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'duplicate';
|
||||
|
||||
const Duplicate: React.FC<Props> = ({ slug }) => {
|
||||
const Duplicate: React.FC<Props> = ({ slug, collection, id }) => {
|
||||
const { push } = useHistory();
|
||||
const { getData } = useForm();
|
||||
const modified = useFormModified();
|
||||
const { toggleModal } = useModal();
|
||||
const { setModified } = useForm();
|
||||
const { serverURL, routes: { api }, localization } = useConfig();
|
||||
const { routes: { admin } } = useConfig();
|
||||
const [hasClicked, setHasClicked] = useState<boolean>(false);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const data = getData();
|
||||
const modalSlug = `duplicate-${id}`;
|
||||
|
||||
push({
|
||||
pathname: `${admin}/collections/${slug}/create`,
|
||||
state: {
|
||||
data,
|
||||
},
|
||||
});
|
||||
}, [push, getData, slug, admin]);
|
||||
const handleClick = useCallback(async (override = false) => {
|
||||
setHasClicked(true);
|
||||
|
||||
if (modified && !override) {
|
||||
toggleModal(modalSlug);
|
||||
return;
|
||||
}
|
||||
|
||||
const create = async (locale?: string): Promise<string | null> => {
|
||||
const localeParam = locale ? `locale=${locale}` : '';
|
||||
const response = await requests.get(`${serverURL}${api}/${slug}/${id}?${localeParam}`);
|
||||
const data = await response.json();
|
||||
const result = await requests.post(`${serverURL}${api}/${slug}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const json = await result.json();
|
||||
|
||||
if (result.status === 201) {
|
||||
return json.doc.id;
|
||||
}
|
||||
json.errors.forEach((error) => toast.error(error.message));
|
||||
return null;
|
||||
};
|
||||
|
||||
let duplicateID;
|
||||
if (localization) {
|
||||
duplicateID = await create(localization.defaultLocale);
|
||||
let abort = false;
|
||||
localization.locales
|
||||
.filter((locale) => locale !== localization.defaultLocale)
|
||||
.forEach(async (locale) => {
|
||||
if (!abort) {
|
||||
const res = await requests.get(`${serverURL}${api}/${slug}/${id}?locale=${locale}`);
|
||||
const localizedDoc = await res.json();
|
||||
const patchResult = await requests.patch(`${serverURL}${api}/${slug}/${duplicateID}?locale=${locale}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(localizedDoc),
|
||||
});
|
||||
if (patchResult.status > 400) {
|
||||
abort = true;
|
||||
const json = await patchResult.json();
|
||||
json.errors.forEach((error) => toast.error(error.message));
|
||||
}
|
||||
}
|
||||
});
|
||||
if (abort) {
|
||||
// delete the duplicate doc to prevent incomplete
|
||||
await requests.delete(`${serverURL}${api}/${slug}/${id}`);
|
||||
}
|
||||
} else {
|
||||
duplicateID = await create();
|
||||
}
|
||||
|
||||
toast.success(`${collection.labels.singular} successfully duplicated.`,
|
||||
{ autoClose: 3000 });
|
||||
|
||||
setModified(false);
|
||||
|
||||
setTimeout(() => {
|
||||
push({
|
||||
pathname: `${admin}/collections/${slug}/${duplicateID}`,
|
||||
});
|
||||
}, 10);
|
||||
}, [modified, localization, collection.labels.singular, setModified, toggleModal, modalSlug, serverURL, api, slug, id, push, admin]);
|
||||
|
||||
const confirm = useCallback(async () => {
|
||||
setHasClicked(false);
|
||||
await handleClick(true);
|
||||
}, [handleClick]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
id="action-duplicate"
|
||||
buttonStyle="none"
|
||||
className={baseClass}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
<React.Fragment>
|
||||
<Button
|
||||
id="action-duplicate"
|
||||
buttonStyle="none"
|
||||
className={baseClass}
|
||||
onClick={() => handleClick(false)}
|
||||
>
|
||||
Duplicate
|
||||
</Button>
|
||||
{modified && hasClicked && (
|
||||
<Modal
|
||||
slug={modalSlug}
|
||||
className={`${baseClass}__modal`}
|
||||
>
|
||||
<MinimalTemplate className={`${baseClass}__modal-template`}>
|
||||
<h1>Confirm duplicate</h1>
|
||||
<p>
|
||||
You have unsaved changes. Would you like to continue to duplicate?
|
||||
</p>
|
||||
<Button
|
||||
id="confirm-cancel"
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={() => toggleModal(modalSlug)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirm}
|
||||
id="confirm-duplicate"
|
||||
>
|
||||
Duplicate without saving changes
|
||||
</Button>
|
||||
</MinimalTemplate>
|
||||
</Modal>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { SanitizedCollectionConfig } from '../../../../collections/config/types';
|
||||
|
||||
export type Props = {
|
||||
slug: string,
|
||||
slug: string
|
||||
collection: SanitizedCollectionConfig
|
||||
id: string
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Modal, useModal } from '@faceless-ui/modal';
|
||||
import Button from '../Button';
|
||||
import MinimalTemplate from '../../templates/Minimal';
|
||||
import { Props } from './types';
|
||||
import { useDocumentInfo } from '../../utilities/DocumentInfo';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -15,13 +16,14 @@ const GenerateConfirmation: React.FC<Props> = (props) => {
|
||||
highlightField,
|
||||
} = props;
|
||||
|
||||
const { toggle } = useModal();
|
||||
const { id } = useDocumentInfo();
|
||||
const { toggleModal } = useModal();
|
||||
|
||||
const modalSlug = 'generate-confirmation';
|
||||
const modalSlug = `generate-confirmation-${id}`;
|
||||
|
||||
const handleGenerate = () => {
|
||||
setKey();
|
||||
toggle(modalSlug);
|
||||
toggleModal(modalSlug);
|
||||
toast.success('New API Key Generated.', { autoClose: 3000 });
|
||||
highlightField(true);
|
||||
};
|
||||
@@ -32,7 +34,7 @@ const GenerateConfirmation: React.FC<Props> = (props) => {
|
||||
size="small"
|
||||
buttonStyle="secondary"
|
||||
onClick={() => {
|
||||
toggle(modalSlug);
|
||||
toggleModal(modalSlug);
|
||||
}}
|
||||
>
|
||||
Generate new API key
|
||||
@@ -57,7 +59,7 @@ const GenerateConfirmation: React.FC<Props> = (props) => {
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toggle(modalSlug);
|
||||
toggleModal(modalSlug);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useWindowInfo } from '@faceless-ui/window-info';
|
||||
import { useScrollInfo } from '@faceless-ui/scroll-info';
|
||||
import { Props } from './types';
|
||||
|
||||
import useThrottledEffect from '../../../hooks/useThrottledEffect';
|
||||
import PopupButton from './PopupButton';
|
||||
|
||||
import './index.scss';
|
||||
import useIntersect from '../../../hooks/useIntersect';
|
||||
|
||||
const baseClass = 'popup';
|
||||
|
||||
@@ -30,26 +28,29 @@ const Popup: React.FC<Props> = (props) => {
|
||||
boundingRef,
|
||||
} = props;
|
||||
|
||||
const { width: windowWidth, height: windowHeight } = useWindowInfo();
|
||||
const [intersectionRef, intersectionEntry] = useIntersect({
|
||||
threshold: 1,
|
||||
rootMargin: '-100px 0px 0px 0px',
|
||||
root: boundingRef?.current || null,
|
||||
});
|
||||
|
||||
const buttonRef = useRef(null);
|
||||
const contentRef = useRef(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [active, setActive] = useState(initActive);
|
||||
const [verticalAlign, setVerticalAlign] = useState(verticalAlignFromProps);
|
||||
const [horizontalAlign, setHorizontalAlign] = useState(horizontalAlignFromProps);
|
||||
|
||||
const { y: scrollY } = useScrollInfo();
|
||||
const { height: windowHeight, width: windowWidth } = useWindowInfo();
|
||||
|
||||
const handleClickOutside = useCallback((e) => {
|
||||
if (contentRef.current.contains(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActive(false);
|
||||
}, []);
|
||||
}, [contentRef]);
|
||||
|
||||
useThrottledEffect(() => {
|
||||
if (contentRef.current && buttonRef.current) {
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
const {
|
||||
left: contentLeftPos,
|
||||
right: contentRightPos,
|
||||
@@ -79,13 +80,11 @@ const Popup: React.FC<Props> = (props) => {
|
||||
|
||||
if (contentTopPos < boundingTopPos && contentBottomPos < boundingBottomPos) {
|
||||
setVerticalAlign('bottom');
|
||||
} else if (contentBottomPos > boundingBottomPos && contentTopPos < boundingTopPos) {
|
||||
} else if (contentBottomPos > boundingBottomPos && contentTopPos > boundingTopPos) {
|
||||
setVerticalAlign('top');
|
||||
}
|
||||
|
||||
setMounted(true);
|
||||
}
|
||||
}, 500, [scrollY, windowHeight, windowWidth]);
|
||||
}, [boundingRef, intersectionEntry, windowHeight, windowWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof onToggleOpen === 'function') onToggleOpen(active);
|
||||
@@ -112,7 +111,7 @@ const Popup: React.FC<Props> = (props) => {
|
||||
`${baseClass}--color-${color}`,
|
||||
`${baseClass}--v-align-${verticalAlign}`,
|
||||
`${baseClass}--h-align-${horizontalAlign}`,
|
||||
(active && mounted) && `${baseClass}--active`,
|
||||
(active) && `${baseClass}--active`,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
@@ -144,7 +143,7 @@ const Popup: React.FC<Props> = (props) => {
|
||||
>
|
||||
<div
|
||||
className={`${baseClass}__wrap`}
|
||||
// TODO: color ::after with bg color
|
||||
ref={intersectionRef}
|
||||
>
|
||||
<div
|
||||
className={`${baseClass}__scroll`}
|
||||
|
||||
@@ -89,6 +89,10 @@ div.react-select {
|
||||
border: $style-stroke-width-s solid var(--theme-elevation-800);
|
||||
line-height: calc(#{$baseline} - #{$style-stroke-width-s * 2});
|
||||
margin: base(.25) base(.5) base(.25) 0;
|
||||
|
||||
&.draggable {
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
|
||||
.rs__multi-value__label {
|
||||
|
||||
@@ -1,10 +1,53 @@
|
||||
import React from 'react';
|
||||
import Select from 'react-select';
|
||||
import { Props } from './types';
|
||||
import React, { MouseEventHandler, useCallback } from 'react';
|
||||
import Select, {
|
||||
components,
|
||||
MultiValueProps,
|
||||
Props as SelectProps,
|
||||
} from 'react-select';
|
||||
import {
|
||||
SortableContainer,
|
||||
SortableContainerProps,
|
||||
SortableElement,
|
||||
SortStartHandler,
|
||||
SortEndHandler,
|
||||
SortableHandle,
|
||||
} from 'react-sortable-hoc';
|
||||
import { arrayMove } from '../../../../utilities/arrayMove';
|
||||
import { Props, Value } from './types';
|
||||
import Chevron from '../../icons/Chevron';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const SortableMultiValue = SortableElement(
|
||||
(props: MultiValueProps<Value>) => {
|
||||
// this prevents the menu from being opened/closed when the user clicks
|
||||
// on a value to begin dragging it. ideally, detecting a click (instead of
|
||||
// a drag) would still focus the control and toggle the menu, but that
|
||||
// requires some magic with refs that are out of scope for this example
|
||||
const onMouseDown: MouseEventHandler<HTMLDivElement> = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const classes = [
|
||||
props.className,
|
||||
!props.isDisabled && 'draggable',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<components.MultiValue
|
||||
{...props}
|
||||
className={classes}
|
||||
innerProps={{ ...props.innerProps, onMouseDown }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
const SortableMultiValueLabel = SortableHandle((props) => <components.MultiValueLabel {...props} />);
|
||||
|
||||
const SortableSelect = SortableContainer(Select) as React.ComponentClass<SelectProps<Value, true> & SortableContainerProps>;
|
||||
|
||||
const ReactSelect: React.FC<Props> = (props) => {
|
||||
const {
|
||||
className,
|
||||
@@ -16,6 +59,9 @@ const ReactSelect: React.FC<Props> = (props) => {
|
||||
placeholder,
|
||||
isSearchable = true,
|
||||
isClearable,
|
||||
isMulti,
|
||||
isSortable,
|
||||
filterOption = undefined,
|
||||
} = props;
|
||||
|
||||
const classes = [
|
||||
@@ -24,6 +70,50 @@ const ReactSelect: React.FC<Props> = (props) => {
|
||||
showError && 'react-select--error',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const onSortStart: SortStartHandler = useCallback(({ helper }) => {
|
||||
const portalNode = helper;
|
||||
if (portalNode && portalNode.style) {
|
||||
portalNode.style.cssText += 'pointer-events: auto; cursor: grabbing;';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSortEnd: SortEndHandler = useCallback(({ oldIndex, newIndex }) => {
|
||||
onChange(arrayMove(value as Value[], oldIndex, newIndex));
|
||||
}, [onChange, value]);
|
||||
|
||||
if (isMulti && isSortable) {
|
||||
return (
|
||||
<SortableSelect
|
||||
useDragHandle
|
||||
// react-sortable-hoc props:
|
||||
axis="xy"
|
||||
onSortStart={onSortStart}
|
||||
onSortEnd={onSortEnd}
|
||||
// small fix for https://github.com/clauderic/react-sortable-hoc/pull/352:
|
||||
getHelperDimensions={({ node }) => node.getBoundingClientRect()}
|
||||
// react-select props:
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
value={value as Value[]}
|
||||
onChange={onChange}
|
||||
disabled={disabled ? 'disabled' : undefined}
|
||||
className={classes}
|
||||
classNamePrefix="rs"
|
||||
options={options}
|
||||
isSearchable={isSearchable}
|
||||
isClearable={isClearable}
|
||||
components={{
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore We're failing to provide a required index prop to SortableElement
|
||||
MultiValue: SortableMultiValue,
|
||||
MultiValueLabel: SortableMultiValueLabel,
|
||||
DropdownIndicator: Chevron,
|
||||
}}
|
||||
filterOption={filterOption}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={placeholder}
|
||||
@@ -37,6 +127,7 @@ const ReactSelect: React.FC<Props> = (props) => {
|
||||
options={options}
|
||||
isSearchable={isSearchable}
|
||||
isClearable={isClearable}
|
||||
filterOption={filterOption}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,11 @@ import { OptionsType, GroupedOptionsType } from 'react-select';
|
||||
|
||||
export type Options = OptionsType<Value> | GroupedOptionsType<Value>;
|
||||
|
||||
export type OptionType = {
|
||||
[key: string]: any,
|
||||
};
|
||||
|
||||
|
||||
export type Value = {
|
||||
label: string
|
||||
value: string | null
|
||||
@@ -16,10 +21,14 @@ export type Props = {
|
||||
showError?: boolean,
|
||||
options: Options
|
||||
isMulti?: boolean,
|
||||
isSortable?: boolean,
|
||||
isDisabled?: boolean
|
||||
onInputChange?: (val: string) => void
|
||||
onMenuScrollToBottom?: () => void
|
||||
placeholder?: string
|
||||
isSearchable?: boolean
|
||||
isClearable?: boolean
|
||||
filterOption?:
|
||||
| (({ label, value, data }: { label: string, value: string, data: OptionType }, search: string) => boolean)
|
||||
| undefined,
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const SaveDraft: React.FC = () => {
|
||||
|
||||
if (collection) {
|
||||
action = `${serverURL}${api}/${collection.slug}${id ? `/${id}` : ''}${search}`;
|
||||
if (id) method = 'PUT';
|
||||
if (id) method = 'PATCH';
|
||||
}
|
||||
|
||||
if (global) {
|
||||
|
||||
@@ -15,17 +15,17 @@ import './index.scss';
|
||||
|
||||
const baseClass = 'status';
|
||||
|
||||
const unPublishModalSlug = 'confirm-un-publish';
|
||||
const revertModalSlug = 'confirm-revert';
|
||||
|
||||
const Status: React.FC<Props> = () => {
|
||||
const { publishedDoc, unpublishedVersions, collection, global, id, getVersions } = useDocumentInfo();
|
||||
const { toggle, closeAll: closeAllModals } = useModal();
|
||||
const { toggleModal } = useModal();
|
||||
const { serverURL, routes: { api } } = useConfig();
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const { reset: resetForm } = useForm();
|
||||
const locale = useLocale();
|
||||
|
||||
const unPublishModalSlug = `confirm-un-publish-${id}`;
|
||||
const revertModalSlug = `confirm-revert-${id}`;
|
||||
|
||||
let statusToRender;
|
||||
|
||||
if (unpublishedVersions?.docs?.length > 0 && publishedDoc) {
|
||||
@@ -55,7 +55,7 @@ const Status: React.FC<Props> = () => {
|
||||
|
||||
if (collection) {
|
||||
url = `${serverURL}${api}/${collection.slug}/${id}?depth=0&locale=${locale}&fallback-locale=null`;
|
||||
method = 'put';
|
||||
method = 'patch';
|
||||
}
|
||||
if (global) {
|
||||
url = `${serverURL}${api}/globals/${global.slug}?depth=0&locale=${locale}&fallback-locale=null`;
|
||||
@@ -92,8 +92,14 @@ const Status: React.FC<Props> = () => {
|
||||
}
|
||||
|
||||
setProcessing(false);
|
||||
closeAllModals();
|
||||
}, [closeAllModals, collection, global, serverURL, api, resetForm, id, locale, getVersions, publishedDoc]);
|
||||
if (action === 'revert') {
|
||||
toggleModal(revertModalSlug);
|
||||
}
|
||||
|
||||
if (action === 'unpublish') {
|
||||
toggleModal(unPublishModalSlug);
|
||||
}
|
||||
}, [collection, global, publishedDoc, serverURL, api, id, locale, resetForm, getVersions, toggleModal, revertModalSlug, unPublishModalSlug]);
|
||||
|
||||
if (statusToRender) {
|
||||
return (
|
||||
@@ -104,7 +110,7 @@ const Status: React.FC<Props> = () => {
|
||||
<React.Fragment>
|
||||
—
|
||||
<Button
|
||||
onClick={() => toggle(unPublishModalSlug)}
|
||||
onClick={() => toggleModal(unPublishModalSlug)}
|
||||
className={`${baseClass}__action`}
|
||||
buttonStyle="none"
|
||||
>
|
||||
@@ -120,7 +126,7 @@ const Status: React.FC<Props> = () => {
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={processing ? undefined : () => toggle(unPublishModalSlug)}
|
||||
onClick={processing ? undefined : () => toggleModal(unPublishModalSlug)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -137,7 +143,7 @@ const Status: React.FC<Props> = () => {
|
||||
<React.Fragment>
|
||||
—
|
||||
<Button
|
||||
onClick={() => toggle(revertModalSlug)}
|
||||
onClick={() => toggleModal(revertModalSlug)}
|
||||
className={`${baseClass}__action`}
|
||||
buttonStyle="none"
|
||||
>
|
||||
@@ -153,7 +159,7 @@ const Status: React.FC<Props> = () => {
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
type="button"
|
||||
onClick={processing ? undefined : () => toggle(revertModalSlug)}
|
||||
onClick={processing ? undefined : () => toggleModal(revertModalSlug)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
|
||||
export type DescriptionFunction = () => string
|
||||
|
||||
export type DescriptionComponent = React.ComponentType
|
||||
export type DescriptionComponent = React.ComponentType<any>
|
||||
|
||||
type Description = string | DescriptionFunction | DescriptionComponent
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const baseClass = 'condition-value-relationship';
|
||||
const maxResultsPerRequest = 10;
|
||||
|
||||
const RelationshipField: React.FC<Props> = (props) => {
|
||||
const { onChange, value, relationTo, hasMany } = props;
|
||||
const { onChange, value, relationTo, hasMany, admin: { isSortable } = {} } = props;
|
||||
|
||||
const {
|
||||
serverURL,
|
||||
@@ -253,6 +253,7 @@ const RelationshipField: React.FC<Props> = (props) => {
|
||||
value={valueToRender}
|
||||
options={options}
|
||||
isMulti={hasMany}
|
||||
isSortable={isSortable}
|
||||
/>
|
||||
)}
|
||||
{errorLoading && (
|
||||
|
||||
@@ -71,7 +71,10 @@ const WhereBuilder: React.FC<Props> = (props) => {
|
||||
|
||||
if (handleChange) handleChange(newWhereQuery as Where);
|
||||
|
||||
if (modifySearchQuery) {
|
||||
const hasExistingConditions = typeof currentParams?.where === 'object' && 'or' in currentParams.where;
|
||||
const hasNewWhereConditions = conditions.length > 0;
|
||||
|
||||
if (modifySearchQuery && ((hasExistingConditions && !hasNewWhereConditions) || hasNewWhereConditions)) {
|
||||
history.replace({
|
||||
search: queryString.stringify({
|
||||
...currentParams,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@import '../../../scss/styles';
|
||||
|
||||
.field-error.tooltip {
|
||||
font-family: var(--font-body);
|
||||
top: 0;
|
||||
bottom: auto;
|
||||
left: auto;
|
||||
@@ -11,4 +12,4 @@
|
||||
span {
|
||||
border-top-color: var(--theme-error-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import equal from 'deep-equal';
|
||||
import ObjectID from 'bson-objectid';
|
||||
import { unflatten, flatten } from 'flatley';
|
||||
import flattenFilters from './flattenFilters';
|
||||
import getSiblingData from './getSiblingData';
|
||||
@@ -65,7 +64,7 @@ function fieldReducer(state: Fields, action): Fields {
|
||||
|
||||
case 'REMOVE': {
|
||||
const newState = { ...state };
|
||||
delete newState[action.path];
|
||||
if (newState[action.path]) delete newState[action.path];
|
||||
return newState;
|
||||
}
|
||||
|
||||
|
||||
@@ -367,6 +367,10 @@ const Form: React.FC<Props> = (props) => {
|
||||
refreshCookie();
|
||||
}, 15000, [fields]);
|
||||
|
||||
// Re-run form validation every second
|
||||
// as fields change, because field validations can
|
||||
// potentially rely on OTHER field values to determine
|
||||
// if they are valid or not (siblingData, data)
|
||||
useThrottledEffect(() => {
|
||||
validateForm();
|
||||
}, 1000, [validateForm, fields]);
|
||||
|
||||
@@ -27,7 +27,7 @@ export type Preferences = {
|
||||
export type Props = {
|
||||
disabled?: boolean
|
||||
onSubmit?: (fields: Fields, data: Data) => void
|
||||
method?: 'get' | 'put' | 'delete' | 'post'
|
||||
method?: 'get' | 'patch' | 'delete' | 'post'
|
||||
action?: string
|
||||
handleResponse?: (res: Response) => void
|
||||
onSuccess?: (json: unknown) => void
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
@import '../../../scss/styles.scss';
|
||||
|
||||
label.field-label {
|
||||
@extend %body;
|
||||
display: flex;
|
||||
padding-bottom: base(.25);
|
||||
color: var(--theme-elevation-800);
|
||||
font-family: var(--font-body);
|
||||
|
||||
.required {
|
||||
color: var(--theme-error-500);
|
||||
margin-left: base(.25);
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const RenderFields: React.FC<Props> = (props) => {
|
||||
forceRender,
|
||||
} = props;
|
||||
|
||||
const [hasRendered, setHasRendered] = useState(false);
|
||||
const [hasRendered, setHasRendered] = useState(Boolean(forceRender));
|
||||
const [intersectionRef, entry] = useIntersect(intersectionObserverOptions);
|
||||
const operation = useOperation();
|
||||
|
||||
|
||||
@@ -1,350 +0,0 @@
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
import { useAuth } from '../../../utilities/Auth';
|
||||
import withCondition from '../../withCondition';
|
||||
import Button from '../../../elements/Button';
|
||||
import reducer, { Row } from '../rowReducer';
|
||||
import { useForm } from '../../Form/context';
|
||||
import buildStateFromSchema from '../../Form/buildStateFromSchema';
|
||||
import useField from '../../useField';
|
||||
import { useLocale } from '../../../utilities/Locale';
|
||||
import Error from '../../Error';
|
||||
import { array } from '../../../../../fields/validations';
|
||||
import Banner from '../../../elements/Banner';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { useOperation } from '../../../utilities/OperationProvider';
|
||||
import { Collapsible } from '../../../elements/Collapsible';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import { Props } from './types';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { ArrayAction } from '../../../elements/ArrayAction';
|
||||
import { scrollToID } from '../../../../utilities/scrollToID';
|
||||
import HiddenInput from '../HiddenInput';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'array-field';
|
||||
|
||||
const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
const {
|
||||
name,
|
||||
path: pathFromProps,
|
||||
fields,
|
||||
fieldTypes,
|
||||
validate = array,
|
||||
required,
|
||||
maxRows,
|
||||
minRows,
|
||||
permissions,
|
||||
admin: {
|
||||
readOnly,
|
||||
description,
|
||||
condition,
|
||||
className,
|
||||
},
|
||||
} = props;
|
||||
|
||||
const path = pathFromProps || name;
|
||||
|
||||
// Handle labeling for Arrays, Global Arrays, and Blocks
|
||||
const getLabels = (p: Props) => {
|
||||
if (p?.labels) return p.labels;
|
||||
if (p?.label) return { singular: p.label, plural: undefined };
|
||||
return { singular: 'Row', plural: 'Rows' };
|
||||
};
|
||||
|
||||
const labels = getLabels(props);
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
const label = props?.label ?? props?.labels?.singular;
|
||||
|
||||
const { preferencesKey } = useDocumentInfo();
|
||||
const { getPreference } = usePreferences();
|
||||
const { setPreference } = usePreferences();
|
||||
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
||||
const formContext = useForm();
|
||||
const { user } = useAuth();
|
||||
const { id } = useDocumentInfo();
|
||||
const locale = useLocale();
|
||||
const operation = useOperation();
|
||||
|
||||
const { dispatchFields } = formContext;
|
||||
|
||||
const memoizedValidate = useCallback((value, options) => {
|
||||
return validate(value, { ...options, minRows, maxRows, required });
|
||||
}, [maxRows, minRows, required, validate]);
|
||||
|
||||
const [disableFormData, setDisableFormData] = useState(false);
|
||||
|
||||
const {
|
||||
showError,
|
||||
errorMessage,
|
||||
value,
|
||||
setValue,
|
||||
} = useField({
|
||||
path,
|
||||
validate: memoizedValidate,
|
||||
disableFormData,
|
||||
condition,
|
||||
});
|
||||
|
||||
const addRow = useCallback(async (rowIndex: number) => {
|
||||
const subFieldState = await buildStateFromSchema({ fieldSchema: fields, operation, id, user, locale });
|
||||
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, fields, path, setValue, value, operation, id, user, locale]);
|
||||
|
||||
const duplicateRow = useCallback(async (rowIndex: number) => {
|
||||
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, path, setValue, value]);
|
||||
|
||||
const removeRow = useCallback((rowIndex: number) => {
|
||||
dispatchRows({ type: 'REMOVE', rowIndex });
|
||||
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
|
||||
setValue(value as number - 1);
|
||||
}, [dispatchRows, dispatchFields, path, value, setValue]);
|
||||
|
||||
const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
|
||||
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
|
||||
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
|
||||
}, [dispatchRows, dispatchFields, path]);
|
||||
|
||||
const onDragEnd = useCallback((result) => {
|
||||
if (!result.destination) return;
|
||||
const sourceIndex = result.source.index;
|
||||
const destinationIndex = result.destination.index;
|
||||
moveRow(sourceIndex, destinationIndex);
|
||||
}, [moveRow]);
|
||||
|
||||
const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => {
|
||||
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
|
||||
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|
||||
|| [];
|
||||
|
||||
if (!collapsed) {
|
||||
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID);
|
||||
} else {
|
||||
newCollapsedState.push(rowID);
|
||||
}
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: newCollapsedState,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [preferencesKey, path, setPreference, rows, getPreference]);
|
||||
|
||||
const toggleCollapseAll = useCallback(async (collapse: boolean) => {
|
||||
dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [path, getPreference, preferencesKey, rows, setPreference]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeRowState = async () => {
|
||||
const data = formContext.getDataByPath<Row[]>(path);
|
||||
const preferences = await getPreference(preferencesKey) || { fields: {} };
|
||||
dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed });
|
||||
};
|
||||
|
||||
initializeRowState();
|
||||
}, [formContext, path, getPreference, preferencesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(rows?.length || 0, true);
|
||||
|
||||
if (rows?.length === 0) {
|
||||
setDisableFormData(false);
|
||||
} else {
|
||||
setDisableFormData(true);
|
||||
}
|
||||
}, [rows, setValue]);
|
||||
|
||||
const hasMaxRows = maxRows && rows?.length >= maxRows;
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (!rows) return null;
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div
|
||||
id={`field-${path.replace(/\./gi, '__')}`}
|
||||
className={classes}
|
||||
>
|
||||
<div className={`${baseClass}__error-wrap`}>
|
||||
<Error
|
||||
showError={showError}
|
||||
message={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<div className={`${baseClass}__header-wrap`}>
|
||||
<h3>{label}</h3>
|
||||
<ul className={`${baseClass}__header-actions`}>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(true)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(false)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Show All
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FieldDescription
|
||||
value={value}
|
||||
description={description}
|
||||
/>
|
||||
</header>
|
||||
<Droppable droppableId="array-drop">
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{rows.length > 0 && rows.map((row, i) => {
|
||||
const rowNumber = i + 1;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={row.id}
|
||||
draggableId={row.id}
|
||||
index={i}
|
||||
isDragDisabled={readOnly}
|
||||
>
|
||||
{(providedDrag) => (
|
||||
<div
|
||||
id={`${path}-row-${i}`}
|
||||
ref={providedDrag.innerRef}
|
||||
{...providedDrag.draggableProps}
|
||||
>
|
||||
<Collapsible
|
||||
collapsed={row.collapsed}
|
||||
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
|
||||
className={`${baseClass}__row`}
|
||||
key={row.id}
|
||||
dragHandleProps={providedDrag.dragHandleProps}
|
||||
header={`${labels.singular} ${rowNumber >= 10 ? rowNumber : `0${rowNumber}`}`}
|
||||
actions={!readOnly ? (
|
||||
<ArrayAction
|
||||
rowCount={rows.length}
|
||||
duplicateRow={duplicateRow}
|
||||
addRow={addRow}
|
||||
moveRow={moveRow}
|
||||
removeRow={removeRow}
|
||||
index={i}
|
||||
/>
|
||||
) : undefined}
|
||||
>
|
||||
<HiddenInput
|
||||
name={`${path}.${i}.id`}
|
||||
value={row.id}
|
||||
/>
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
forceRender
|
||||
readOnly={readOnly}
|
||||
fieldTypes={fieldTypes}
|
||||
permissions={permissions?.fields}
|
||||
fieldSchema={fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
{(rows.length < minRows || (required && rows.length === 0)) && (
|
||||
<Banner type="error">
|
||||
This field requires at least
|
||||
{' '}
|
||||
{minRows
|
||||
? `${minRows} ${labels.plural}`
|
||||
: `1 ${labels.singular}`}
|
||||
</Banner>
|
||||
)}
|
||||
{(rows.length === 0 && readOnly) && (
|
||||
<Banner>
|
||||
This field has no
|
||||
{' '}
|
||||
{labels.plural}
|
||||
.
|
||||
</Banner>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
{(!readOnly && !hasMaxRows) && (
|
||||
<div className={`${baseClass}__add-button-wrap`}>
|
||||
<Button
|
||||
onClick={() => addRow(value as number)}
|
||||
buttonStyle="icon-label"
|
||||
icon="plus"
|
||||
iconStyle="with-border"
|
||||
iconPosition="left"
|
||||
>
|
||||
{`Add ${labels.singular}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default withCondition(ArrayFieldType);
|
||||
@@ -1,13 +1,350 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import Loading from '../../../elements/Loading';
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
import { useAuth } from '../../../utilities/Auth';
|
||||
import withCondition from '../../withCondition';
|
||||
import Button from '../../../elements/Button';
|
||||
import reducer, { Row } from '../rowReducer';
|
||||
import { useForm } from '../../Form/context';
|
||||
import buildStateFromSchema from '../../Form/buildStateFromSchema';
|
||||
import useField from '../../useField';
|
||||
import { useLocale } from '../../../utilities/Locale';
|
||||
import Error from '../../Error';
|
||||
import { array } from '../../../../../fields/validations';
|
||||
import Banner from '../../../elements/Banner';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { useOperation } from '../../../utilities/OperationProvider';
|
||||
import { Collapsible } from '../../../elements/Collapsible';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import { Props } from './types';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { ArrayAction } from '../../../elements/ArrayAction';
|
||||
import { scrollToID } from '../../../../utilities/scrollToID';
|
||||
import HiddenInput from '../HiddenInput';
|
||||
|
||||
const ArrayField = lazy(() => import('./Array'));
|
||||
import './index.scss';
|
||||
|
||||
const ArrayFieldType: React.FC<Props> = (props) => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ArrayField {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
const baseClass = 'array-field';
|
||||
|
||||
export default ArrayFieldType;
|
||||
const ArrayFieldType: React.FC<Props> = (props) => {
|
||||
const {
|
||||
name,
|
||||
path: pathFromProps,
|
||||
fields,
|
||||
fieldTypes,
|
||||
validate = array,
|
||||
required,
|
||||
maxRows,
|
||||
minRows,
|
||||
permissions,
|
||||
admin: {
|
||||
readOnly,
|
||||
description,
|
||||
condition,
|
||||
className,
|
||||
},
|
||||
} = props;
|
||||
|
||||
const path = pathFromProps || name;
|
||||
|
||||
// Handle labeling for Arrays, Global Arrays, and Blocks
|
||||
const getLabels = (p: Props) => {
|
||||
if (p?.labels) return p.labels;
|
||||
if (p?.label) return { singular: p.label, plural: undefined };
|
||||
return { singular: 'Row', plural: 'Rows' };
|
||||
};
|
||||
|
||||
const labels = getLabels(props);
|
||||
// eslint-disable-next-line react/destructuring-assignment
|
||||
const label = props?.label ?? props?.labels?.singular;
|
||||
|
||||
const { preferencesKey } = useDocumentInfo();
|
||||
const { getPreference } = usePreferences();
|
||||
const { setPreference } = usePreferences();
|
||||
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
||||
const formContext = useForm();
|
||||
const { user } = useAuth();
|
||||
const { id } = useDocumentInfo();
|
||||
const locale = useLocale();
|
||||
const operation = useOperation();
|
||||
|
||||
const { dispatchFields } = formContext;
|
||||
|
||||
const memoizedValidate = useCallback((value, options) => {
|
||||
return validate(value, { ...options, minRows, maxRows, required });
|
||||
}, [maxRows, minRows, required, validate]);
|
||||
|
||||
const [disableFormData, setDisableFormData] = useState(false);
|
||||
|
||||
const {
|
||||
showError,
|
||||
errorMessage,
|
||||
value,
|
||||
setValue,
|
||||
} = useField({
|
||||
path,
|
||||
validate: memoizedValidate,
|
||||
disableFormData,
|
||||
condition,
|
||||
});
|
||||
|
||||
const addRow = useCallback(async (rowIndex: number) => {
|
||||
const subFieldState = await buildStateFromSchema({ fieldSchema: fields, operation, id, user, locale });
|
||||
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, fields, path, setValue, value, operation, id, user, locale]);
|
||||
|
||||
const duplicateRow = useCallback(async (rowIndex: number) => {
|
||||
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, path, setValue, value]);
|
||||
|
||||
const removeRow = useCallback((rowIndex: number) => {
|
||||
dispatchRows({ type: 'REMOVE', rowIndex });
|
||||
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
|
||||
setValue(value as number - 1);
|
||||
}, [dispatchRows, dispatchFields, path, value, setValue]);
|
||||
|
||||
const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
|
||||
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
|
||||
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
|
||||
}, [dispatchRows, dispatchFields, path]);
|
||||
|
||||
const onDragEnd = useCallback((result) => {
|
||||
if (!result.destination) return;
|
||||
const sourceIndex = result.source.index;
|
||||
const destinationIndex = result.destination.index;
|
||||
moveRow(sourceIndex, destinationIndex);
|
||||
}, [moveRow]);
|
||||
|
||||
const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => {
|
||||
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
|
||||
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|
||||
|| [];
|
||||
|
||||
if (!collapsed) {
|
||||
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID);
|
||||
} else {
|
||||
newCollapsedState.push(rowID);
|
||||
}
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: newCollapsedState,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [preferencesKey, path, setPreference, rows, getPreference]);
|
||||
|
||||
const toggleCollapseAll = useCallback(async (collapse: boolean) => {
|
||||
dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [path, getPreference, preferencesKey, rows, setPreference]);
|
||||
|
||||
useEffect(() => {
|
||||
const initializeRowState = async () => {
|
||||
const data = formContext.getDataByPath<Row[]>(path);
|
||||
const preferences = await getPreference(preferencesKey) || { fields: {} };
|
||||
dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed });
|
||||
};
|
||||
|
||||
initializeRowState();
|
||||
}, [formContext, path, getPreference, preferencesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(rows?.length || 0, true);
|
||||
|
||||
if (rows?.length === 0) {
|
||||
setDisableFormData(false);
|
||||
} else {
|
||||
setDisableFormData(true);
|
||||
}
|
||||
}, [rows, setValue]);
|
||||
|
||||
const hasMaxRows = maxRows && rows?.length >= maxRows;
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (!rows) return null;
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div
|
||||
id={`field-${path.replace(/\./gi, '__')}`}
|
||||
className={classes}
|
||||
>
|
||||
<div className={`${baseClass}__error-wrap`}>
|
||||
<Error
|
||||
showError={showError}
|
||||
message={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<div className={`${baseClass}__header-wrap`}>
|
||||
<h3>{label}</h3>
|
||||
<ul className={`${baseClass}__header-actions`}>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(true)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(false)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Show All
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FieldDescription
|
||||
value={value}
|
||||
description={description}
|
||||
/>
|
||||
</header>
|
||||
<Droppable droppableId="array-drop">
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{rows.length > 0 && rows.map((row, i) => {
|
||||
const rowNumber = i + 1;
|
||||
|
||||
return (
|
||||
<Draggable
|
||||
key={row.id}
|
||||
draggableId={row.id}
|
||||
index={i}
|
||||
isDragDisabled={readOnly}
|
||||
>
|
||||
{(providedDrag) => (
|
||||
<div
|
||||
id={`${path}-row-${i}`}
|
||||
ref={providedDrag.innerRef}
|
||||
{...providedDrag.draggableProps}
|
||||
>
|
||||
<Collapsible
|
||||
collapsed={row.collapsed}
|
||||
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
|
||||
className={`${baseClass}__row`}
|
||||
key={row.id}
|
||||
dragHandleProps={providedDrag.dragHandleProps}
|
||||
header={`${labels.singular} ${rowNumber >= 10 ? rowNumber : `0${rowNumber}`}`}
|
||||
actions={!readOnly ? (
|
||||
<ArrayAction
|
||||
rowCount={rows.length}
|
||||
duplicateRow={duplicateRow}
|
||||
addRow={addRow}
|
||||
moveRow={moveRow}
|
||||
removeRow={removeRow}
|
||||
index={i}
|
||||
/>
|
||||
) : undefined}
|
||||
>
|
||||
<HiddenInput
|
||||
name={`${path}.${i}.id`}
|
||||
value={row.id}
|
||||
/>
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
forceRender
|
||||
readOnly={readOnly}
|
||||
fieldTypes={fieldTypes}
|
||||
permissions={permissions?.fields}
|
||||
fieldSchema={fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})}
|
||||
{(rows.length < minRows || (required && rows.length === 0)) && (
|
||||
<Banner type="error">
|
||||
This field requires at least
|
||||
{' '}
|
||||
{minRows
|
||||
? `${minRows} ${labels.plural}`
|
||||
: `1 ${labels.singular}`}
|
||||
</Banner>
|
||||
)}
|
||||
{(rows.length === 0 && readOnly) && (
|
||||
<Banner>
|
||||
This field has no
|
||||
{' '}
|
||||
{labels.plural}
|
||||
.
|
||||
</Banner>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
{(!readOnly && !hasMaxRows) && (
|
||||
<div className={`${baseClass}__add-button-wrap`}>
|
||||
<Button
|
||||
onClick={() => addRow(value as number)}
|
||||
buttonStyle="icon-label"
|
||||
icon="plus"
|
||||
iconStyle="with-border"
|
||||
iconPosition="left"
|
||||
>
|
||||
{`Add ${labels.singular}`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default withCondition(ArrayFieldType);
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
|
||||
import { useAuth } from '../../../utilities/Auth';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { useLocale } from '../../../utilities/Locale';
|
||||
import withCondition from '../../withCondition';
|
||||
import Button from '../../../elements/Button';
|
||||
import reducer, { Row } from '../rowReducer';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { useForm } from '../../Form/context';
|
||||
import buildStateFromSchema from '../../Form/buildStateFromSchema';
|
||||
import Error from '../../Error';
|
||||
import useField from '../../useField';
|
||||
import Popup from '../../../elements/Popup';
|
||||
import BlockSelector from './BlockSelector';
|
||||
import { blocks as blocksValidator } from '../../../../../fields/validations';
|
||||
import Banner from '../../../elements/Banner';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { Props } from './types';
|
||||
import { useOperation } from '../../../utilities/OperationProvider';
|
||||
import { Collapsible } from '../../../elements/Collapsible';
|
||||
import { ArrayAction } from '../../../elements/ArrayAction';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import SectionTitle from './SectionTitle';
|
||||
import Pill from '../../../elements/Pill';
|
||||
import { scrollToID } from '../../../../utilities/scrollToID';
|
||||
import HiddenInput from '../HiddenInput';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'blocks-field';
|
||||
|
||||
const labelDefaults = {
|
||||
singular: 'Block',
|
||||
plural: 'Blocks',
|
||||
};
|
||||
|
||||
const Blocks: React.FC<Props> = (props) => {
|
||||
const {
|
||||
label,
|
||||
name,
|
||||
path: pathFromProps,
|
||||
blocks,
|
||||
labels = labelDefaults,
|
||||
fieldTypes,
|
||||
maxRows,
|
||||
minRows,
|
||||
required,
|
||||
validate = blocksValidator,
|
||||
permissions,
|
||||
admin: {
|
||||
readOnly,
|
||||
description,
|
||||
condition,
|
||||
className,
|
||||
},
|
||||
} = props;
|
||||
|
||||
const path = pathFromProps || name;
|
||||
|
||||
const { preferencesKey } = useDocumentInfo();
|
||||
const { getPreference } = usePreferences();
|
||||
const { setPreference } = usePreferences();
|
||||
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
||||
const formContext = useForm();
|
||||
const { user } = useAuth();
|
||||
const { id } = useDocumentInfo();
|
||||
const locale = useLocale();
|
||||
const operation = useOperation();
|
||||
const { dispatchFields } = formContext;
|
||||
|
||||
const memoizedValidate = useCallback((value, options) => {
|
||||
return validate(value, { ...options, minRows, maxRows, required });
|
||||
}, [maxRows, minRows, required, validate]);
|
||||
|
||||
const [disableFormData, setDisableFormData] = useState(false);
|
||||
const [selectorIndexOpen, setSelectorIndexOpen] = useState<number>();
|
||||
|
||||
const {
|
||||
showError,
|
||||
errorMessage,
|
||||
value,
|
||||
setValue,
|
||||
} = useField<number>({
|
||||
path,
|
||||
validate: memoizedValidate,
|
||||
disableFormData,
|
||||
condition,
|
||||
});
|
||||
|
||||
const onAddPopupToggle = useCallback((open) => {
|
||||
if (!open) {
|
||||
setSelectorIndexOpen(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addRow = useCallback(async (rowIndex: number, blockType: string) => {
|
||||
const block = blocks.find((potentialBlock) => potentialBlock.slug === blockType);
|
||||
const subFieldState = await buildStateFromSchema({ fieldSchema: block.fields, operation, id, user, locale });
|
||||
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path, blockType });
|
||||
dispatchRows({ type: 'ADD', rowIndex, blockType });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [path, setValue, value, blocks, dispatchFields, operation, id, user, locale]);
|
||||
|
||||
const duplicateRow = useCallback(async (rowIndex: number, blockType: string) => {
|
||||
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex, blockType });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, path, setValue, value]);
|
||||
|
||||
const removeRow = useCallback((rowIndex: number) => {
|
||||
dispatchRows({ type: 'REMOVE', rowIndex });
|
||||
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
|
||||
setValue(value as number - 1);
|
||||
}, [path, setValue, value, dispatchFields]);
|
||||
|
||||
const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
|
||||
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
|
||||
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
|
||||
}, [dispatchRows, dispatchFields, path]);
|
||||
|
||||
const onDragEnd = useCallback((result) => {
|
||||
if (!result.destination) return;
|
||||
const sourceIndex = result.source.index;
|
||||
const destinationIndex = result.destination.index;
|
||||
moveRow(sourceIndex, destinationIndex);
|
||||
}, [moveRow]);
|
||||
|
||||
const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => {
|
||||
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
|
||||
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|
||||
|| [];
|
||||
|
||||
if (!collapsed) {
|
||||
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID);
|
||||
} else {
|
||||
newCollapsedState.push(rowID);
|
||||
}
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: newCollapsedState,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [preferencesKey, getPreference, path, setPreference, rows]);
|
||||
|
||||
const toggleCollapseAll = useCallback(async (collapse: boolean) => {
|
||||
dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [getPreference, path, preferencesKey, rows, setPreference]);
|
||||
|
||||
// Set row count on mount and when form context is reset
|
||||
useEffect(() => {
|
||||
const initializeRowState = async () => {
|
||||
const data = formContext.getDataByPath<Row[]>(path);
|
||||
const preferences = await getPreference(preferencesKey) || { fields: {} };
|
||||
dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed });
|
||||
};
|
||||
|
||||
initializeRowState();
|
||||
}, [formContext, path, getPreference, preferencesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(rows?.length || 0, true);
|
||||
|
||||
if (rows?.length === 0) {
|
||||
setDisableFormData(false);
|
||||
} else {
|
||||
setDisableFormData(true);
|
||||
}
|
||||
}, [rows, setValue]);
|
||||
|
||||
const hasMaxRows = maxRows && rows?.length >= maxRows;
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (!rows) return null;
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div
|
||||
id={`field-${path.replace(/\./gi, '__')}`}
|
||||
className={classes}
|
||||
>
|
||||
<div className={`${baseClass}__error-wrap`}>
|
||||
<Error
|
||||
showError={showError}
|
||||
message={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<div className={`${baseClass}__header-wrap`}>
|
||||
<h3>{label}</h3>
|
||||
<ul className={`${baseClass}__header-actions`}>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(true)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(false)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Show All
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FieldDescription
|
||||
value={value}
|
||||
description={description}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<Droppable
|
||||
droppableId="blocks-drop"
|
||||
isDropDisabled={readOnly}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{rows.length > 0 && rows.map((row, i) => {
|
||||
const { blockType } = row;
|
||||
const blockToRender = blocks.find((block) => block.slug === blockType);
|
||||
|
||||
const rowNumber = i + 1;
|
||||
|
||||
if (blockToRender) {
|
||||
return (
|
||||
<Draggable
|
||||
key={row.id}
|
||||
draggableId={row.id}
|
||||
index={i}
|
||||
isDragDisabled={readOnly}
|
||||
>
|
||||
{(providedDrag) => (
|
||||
<div
|
||||
id={`${path}-row-${i}`}
|
||||
ref={providedDrag.innerRef}
|
||||
{...providedDrag.draggableProps}
|
||||
>
|
||||
<Collapsible
|
||||
collapsed={row.collapsed}
|
||||
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
|
||||
className={`${baseClass}__row`}
|
||||
key={row.id}
|
||||
dragHandleProps={providedDrag.dragHandleProps}
|
||||
header={(
|
||||
<div className={`${baseClass}__block-header`}>
|
||||
<span className={`${baseClass}__block-number`}>
|
||||
{rowNumber >= 10 ? rowNumber : `0${rowNumber}`}
|
||||
</span>
|
||||
<Pill
|
||||
pillStyle="white"
|
||||
className={`${baseClass}__block-pill ${baseClass}__block-pill-${blockType}`}
|
||||
>
|
||||
{blockToRender.labels.singular}
|
||||
</Pill>
|
||||
<SectionTitle
|
||||
path={`${path}.${i}.blockName`}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
actions={!readOnly ? (
|
||||
<React.Fragment>
|
||||
<Popup
|
||||
key={`${blockType}-${i}`}
|
||||
forceOpen={selectorIndexOpen === i}
|
||||
onToggleOpen={onAddPopupToggle}
|
||||
buttonType="none"
|
||||
size="large"
|
||||
horizontalAlign="right"
|
||||
render={({ close }) => (
|
||||
<BlockSelector
|
||||
blocks={blocks}
|
||||
addRow={addRow}
|
||||
addRowIndex={i}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ArrayAction
|
||||
rowCount={rows.length}
|
||||
duplicateRow={() => duplicateRow(i, blockType)}
|
||||
addRow={() => setSelectorIndexOpen(i)}
|
||||
moveRow={moveRow}
|
||||
removeRow={removeRow}
|
||||
index={i}
|
||||
/>
|
||||
</React.Fragment>
|
||||
) : undefined}
|
||||
>
|
||||
<HiddenInput
|
||||
name={`${path}.${i}.id`}
|
||||
value={row.id}
|
||||
/>
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
forceRender
|
||||
readOnly={readOnly}
|
||||
fieldTypes={fieldTypes}
|
||||
permissions={permissions?.fields}
|
||||
fieldSchema={blockToRender.fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{(rows.length < minRows || (required && rows.length === 0)) && (
|
||||
<Banner type="error">
|
||||
This field requires at least
|
||||
{' '}
|
||||
{`${minRows || 1} ${minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural}`}
|
||||
</Banner>
|
||||
)}
|
||||
{(rows.length === 0 && readOnly) && (
|
||||
<Banner>
|
||||
This field has no
|
||||
{' '}
|
||||
{labels.plural}
|
||||
.
|
||||
</Banner>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
|
||||
{(!readOnly && !hasMaxRows) && (
|
||||
<div className={`${baseClass}__add-button-wrap`}>
|
||||
<Popup
|
||||
buttonType="custom"
|
||||
size="large"
|
||||
horizontalAlign="left"
|
||||
button={(
|
||||
<Button
|
||||
buttonStyle="icon-label"
|
||||
icon="plus"
|
||||
iconPosition="left"
|
||||
iconStyle="with-border"
|
||||
>
|
||||
{`Add ${labels.singular}`}
|
||||
</Button>
|
||||
)}
|
||||
render={({ close }) => (
|
||||
<BlockSelector
|
||||
blocks={blocks}
|
||||
addRow={addRow}
|
||||
addRowIndex={value}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default withCondition(Blocks);
|
||||
@@ -1,13 +1,417 @@
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import Loading from '../../../elements/Loading';
|
||||
import React, { useCallback, useEffect, useReducer, useState } from 'react';
|
||||
import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd';
|
||||
|
||||
import { useAuth } from '../../../utilities/Auth';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { useLocale } from '../../../utilities/Locale';
|
||||
import withCondition from '../../withCondition';
|
||||
import Button from '../../../elements/Button';
|
||||
import reducer, { Row } from '../rowReducer';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { useForm } from '../../Form/context';
|
||||
import buildStateFromSchema from '../../Form/buildStateFromSchema';
|
||||
import Error from '../../Error';
|
||||
import useField from '../../useField';
|
||||
import Popup from '../../../elements/Popup';
|
||||
import BlockSelector from './BlockSelector';
|
||||
import { blocks as blocksValidator } from '../../../../../fields/validations';
|
||||
import Banner from '../../../elements/Banner';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { Props } from './types';
|
||||
import { useOperation } from '../../../utilities/OperationProvider';
|
||||
import { Collapsible } from '../../../elements/Collapsible';
|
||||
import { ArrayAction } from '../../../elements/ArrayAction';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import SectionTitle from './SectionTitle';
|
||||
import Pill from '../../../elements/Pill';
|
||||
import { scrollToID } from '../../../../utilities/scrollToID';
|
||||
import HiddenInput from '../HiddenInput';
|
||||
|
||||
const Blocks = lazy(() => import('./Blocks'));
|
||||
import './index.scss';
|
||||
|
||||
const BlocksField: React.FC<Props> = (props) => (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Blocks {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
const baseClass = 'blocks-field';
|
||||
|
||||
export default BlocksField;
|
||||
const labelDefaults = {
|
||||
singular: 'Block',
|
||||
plural: 'Blocks',
|
||||
};
|
||||
|
||||
const Index: React.FC<Props> = (props) => {
|
||||
const {
|
||||
label,
|
||||
name,
|
||||
path: pathFromProps,
|
||||
blocks,
|
||||
labels = labelDefaults,
|
||||
fieldTypes,
|
||||
maxRows,
|
||||
minRows,
|
||||
required,
|
||||
validate = blocksValidator,
|
||||
permissions,
|
||||
admin: {
|
||||
readOnly,
|
||||
description,
|
||||
condition,
|
||||
className,
|
||||
},
|
||||
} = props;
|
||||
|
||||
const path = pathFromProps || name;
|
||||
|
||||
const { preferencesKey } = useDocumentInfo();
|
||||
const { getPreference } = usePreferences();
|
||||
const { setPreference } = usePreferences();
|
||||
const [rows, dispatchRows] = useReducer(reducer, undefined);
|
||||
const formContext = useForm();
|
||||
const { user } = useAuth();
|
||||
const { id } = useDocumentInfo();
|
||||
const locale = useLocale();
|
||||
const operation = useOperation();
|
||||
const { dispatchFields } = formContext;
|
||||
|
||||
const memoizedValidate = useCallback((value, options) => {
|
||||
return validate(value, { ...options, minRows, maxRows, required });
|
||||
}, [maxRows, minRows, required, validate]);
|
||||
|
||||
const [disableFormData, setDisableFormData] = useState(false);
|
||||
const [selectorIndexOpen, setSelectorIndexOpen] = useState<number>();
|
||||
|
||||
const {
|
||||
showError,
|
||||
errorMessage,
|
||||
value,
|
||||
setValue,
|
||||
} = useField<number>({
|
||||
path,
|
||||
validate: memoizedValidate,
|
||||
disableFormData,
|
||||
condition,
|
||||
});
|
||||
|
||||
const onAddPopupToggle = useCallback((open) => {
|
||||
if (!open) {
|
||||
setSelectorIndexOpen(undefined);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addRow = useCallback(async (rowIndex: number, blockType: string) => {
|
||||
const block = blocks.find((potentialBlock) => potentialBlock.slug === blockType);
|
||||
const subFieldState = await buildStateFromSchema({ fieldSchema: block.fields, operation, id, user, locale });
|
||||
dispatchFields({ type: 'ADD_ROW', rowIndex, subFieldState, path, blockType });
|
||||
dispatchRows({ type: 'ADD', rowIndex, blockType });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [path, setValue, value, blocks, dispatchFields, operation, id, user, locale]);
|
||||
|
||||
const duplicateRow = useCallback(async (rowIndex: number, blockType: string) => {
|
||||
dispatchFields({ type: 'DUPLICATE_ROW', rowIndex, path });
|
||||
dispatchRows({ type: 'ADD', rowIndex, blockType });
|
||||
setValue(value as number + 1);
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToID(`${path}-row-${rowIndex + 1}`);
|
||||
}, 0);
|
||||
}, [dispatchRows, dispatchFields, path, setValue, value]);
|
||||
|
||||
const removeRow = useCallback((rowIndex: number) => {
|
||||
dispatchRows({ type: 'REMOVE', rowIndex });
|
||||
dispatchFields({ type: 'REMOVE_ROW', rowIndex, path });
|
||||
setValue(value as number - 1);
|
||||
}, [path, setValue, value, dispatchFields]);
|
||||
|
||||
const moveRow = useCallback((moveFromIndex: number, moveToIndex: number) => {
|
||||
dispatchRows({ type: 'MOVE', moveFromIndex, moveToIndex });
|
||||
dispatchFields({ type: 'MOVE_ROW', moveFromIndex, moveToIndex, path });
|
||||
}, [dispatchRows, dispatchFields, path]);
|
||||
|
||||
const onDragEnd = useCallback((result) => {
|
||||
if (!result.destination) return;
|
||||
const sourceIndex = result.source.index;
|
||||
const destinationIndex = result.destination.index;
|
||||
moveRow(sourceIndex, destinationIndex);
|
||||
}, [moveRow]);
|
||||
|
||||
const setCollapse = useCallback(async (rowID: string, collapsed: boolean) => {
|
||||
dispatchRows({ type: 'SET_COLLAPSE', id: rowID, collapsed });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed
|
||||
.filter((filterID) => (rows.find((row) => row.id === filterID)))
|
||||
|| [];
|
||||
|
||||
if (!collapsed) {
|
||||
newCollapsedState = newCollapsedState.filter((existingID) => existingID !== rowID);
|
||||
} else {
|
||||
newCollapsedState.push(rowID);
|
||||
}
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: newCollapsedState,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [preferencesKey, getPreference, path, setPreference, rows]);
|
||||
|
||||
const toggleCollapseAll = useCallback(async (collapse: boolean) => {
|
||||
dispatchRows({ type: 'SET_ALL_COLLAPSED', collapse });
|
||||
|
||||
if (preferencesKey) {
|
||||
const preferencesToSet = await getPreference(preferencesKey) || { fields: {} };
|
||||
|
||||
setPreference(preferencesKey, {
|
||||
...preferencesToSet,
|
||||
fields: {
|
||||
...preferencesToSet?.fields || {},
|
||||
[path]: {
|
||||
...preferencesToSet?.fields?.[path],
|
||||
collapsed: collapse ? rows.map(({ id: rowID }) => rowID) : [],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [getPreference, path, preferencesKey, rows, setPreference]);
|
||||
|
||||
// Set row count on mount and when form context is reset
|
||||
useEffect(() => {
|
||||
const initializeRowState = async () => {
|
||||
const data = formContext.getDataByPath<Row[]>(path);
|
||||
const preferences = await getPreference(preferencesKey) || { fields: {} };
|
||||
dispatchRows({ type: 'SET_ALL', data: data || [], collapsedState: preferences?.fields?.[path]?.collapsed });
|
||||
};
|
||||
|
||||
initializeRowState();
|
||||
}, [formContext, path, getPreference, preferencesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(rows?.length || 0, true);
|
||||
|
||||
if (rows?.length === 0) {
|
||||
setDisableFormData(false);
|
||||
} else {
|
||||
setDisableFormData(true);
|
||||
}
|
||||
}, [rows, setValue]);
|
||||
|
||||
const hasMaxRows = maxRows && rows?.length >= maxRows;
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
if (!rows) return null;
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<div
|
||||
id={`field-${path.replace(/\./gi, '__')}`}
|
||||
className={classes}
|
||||
>
|
||||
<div className={`${baseClass}__error-wrap`}>
|
||||
<Error
|
||||
showError={showError}
|
||||
message={errorMessage}
|
||||
/>
|
||||
</div>
|
||||
<header className={`${baseClass}__header`}>
|
||||
<div className={`${baseClass}__header-wrap`}>
|
||||
<h3>{label}</h3>
|
||||
<ul className={`${baseClass}__header-actions`}>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(true)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleCollapseAll(false)}
|
||||
className={`${baseClass}__header-action`}
|
||||
>
|
||||
Show All
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<FieldDescription
|
||||
value={value}
|
||||
description={description}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<Droppable
|
||||
droppableId="blocks-drop"
|
||||
isDropDisabled={readOnly}
|
||||
>
|
||||
{(provided) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
>
|
||||
{rows.length > 0 && rows.map((row, i) => {
|
||||
const { blockType } = row;
|
||||
const blockToRender = blocks.find((block) => block.slug === blockType);
|
||||
|
||||
const rowNumber = i + 1;
|
||||
|
||||
if (blockToRender) {
|
||||
return (
|
||||
<Draggable
|
||||
key={row.id}
|
||||
draggableId={row.id}
|
||||
index={i}
|
||||
isDragDisabled={readOnly}
|
||||
>
|
||||
{(providedDrag) => (
|
||||
<div
|
||||
id={`${path}-row-${i}`}
|
||||
ref={providedDrag.innerRef}
|
||||
{...providedDrag.draggableProps}
|
||||
>
|
||||
<Collapsible
|
||||
collapsed={row.collapsed}
|
||||
onToggle={(collapsed) => setCollapse(row.id, collapsed)}
|
||||
className={`${baseClass}__row`}
|
||||
key={row.id}
|
||||
dragHandleProps={providedDrag.dragHandleProps}
|
||||
header={(
|
||||
<div className={`${baseClass}__block-header`}>
|
||||
<span className={`${baseClass}__block-number`}>
|
||||
{rowNumber >= 10 ? rowNumber : `0${rowNumber}`}
|
||||
</span>
|
||||
<Pill
|
||||
pillStyle="white"
|
||||
className={`${baseClass}__block-pill ${baseClass}__block-pill-${blockType}`}
|
||||
>
|
||||
{blockToRender.labels.singular}
|
||||
</Pill>
|
||||
<SectionTitle
|
||||
path={`${path}.${i}.blockName`}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
actions={!readOnly ? (
|
||||
<React.Fragment>
|
||||
<Popup
|
||||
key={`${blockType}-${i}`}
|
||||
forceOpen={selectorIndexOpen === i}
|
||||
onToggleOpen={onAddPopupToggle}
|
||||
buttonType="none"
|
||||
size="large"
|
||||
horizontalAlign="right"
|
||||
render={({ close }) => (
|
||||
<BlockSelector
|
||||
blocks={blocks}
|
||||
addRow={addRow}
|
||||
addRowIndex={i}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ArrayAction
|
||||
rowCount={rows.length}
|
||||
duplicateRow={() => duplicateRow(i, blockType)}
|
||||
addRow={() => setSelectorIndexOpen(i)}
|
||||
moveRow={moveRow}
|
||||
removeRow={removeRow}
|
||||
index={i}
|
||||
/>
|
||||
</React.Fragment>
|
||||
) : undefined}
|
||||
>
|
||||
<HiddenInput
|
||||
name={`${path}.${i}.id`}
|
||||
value={row.id}
|
||||
/>
|
||||
<RenderFields
|
||||
className={`${baseClass}__fields`}
|
||||
forceRender
|
||||
readOnly={readOnly}
|
||||
fieldTypes={fieldTypes}
|
||||
permissions={permissions?.fields}
|
||||
fieldSchema={blockToRender.fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path}.${i}${fieldAffectsData(field) ? `.${field.name}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
{(rows.length < minRows || (required && rows.length === 0)) && (
|
||||
<Banner type="error">
|
||||
This field requires at least
|
||||
{' '}
|
||||
{`${minRows || 1} ${minRows === 1 || typeof minRows === 'undefined' ? labels.singular : labels.plural}`}
|
||||
</Banner>
|
||||
)}
|
||||
{(rows.length === 0 && readOnly) && (
|
||||
<Banner>
|
||||
This field has no
|
||||
{' '}
|
||||
{labels.plural}
|
||||
.
|
||||
</Banner>
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
|
||||
{(!readOnly && !hasMaxRows) && (
|
||||
<div className={`${baseClass}__add-button-wrap`}>
|
||||
<Popup
|
||||
buttonType="custom"
|
||||
size="large"
|
||||
horizontalAlign="left"
|
||||
button={(
|
||||
<Button
|
||||
buttonStyle="icon-label"
|
||||
icon="plus"
|
||||
iconPosition="left"
|
||||
iconStyle="with-border"
|
||||
>
|
||||
{`Add ${labels.singular}`}
|
||||
</Button>
|
||||
)}
|
||||
render={({ close }) => (
|
||||
<BlockSelector
|
||||
blocks={blocks}
|
||||
addRow={addRow}
|
||||
addRowIndex={value}
|
||||
close={close}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DragDropContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default withCondition(Index);
|
||||
|
||||
@@ -2,7 +2,14 @@ import React, { useCallback, useState } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import { highlight, languages } from 'prismjs/components/prism-core';
|
||||
import 'prismjs/components/prism-clike';
|
||||
import 'prismjs/components/prism-css';
|
||||
import 'prismjs/components/prism-markup';
|
||||
import 'prismjs/components/prism-javascript';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-tsx';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import useField from '../../useField';
|
||||
import withCondition from '../../withCondition';
|
||||
import Label from '../../Label';
|
||||
|
||||
@@ -2,13 +2,13 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import RenderFields from '../../RenderFields';
|
||||
import withCondition from '../../withCondition';
|
||||
import { Props } from './types';
|
||||
import { fieldAffectsData } from '../../../../../fields/config/types';
|
||||
import { Collapsible } from '../../../elements/Collapsible';
|
||||
import toKebabCase from '../../../../../utilities/toKebabCase';
|
||||
import { usePreferences } from '../../../utilities/Preferences';
|
||||
import { DocumentPreferences } from '../../../../../preferences/types';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import { getFieldPath } from '../getFieldPath';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -78,7 +78,7 @@ const CollapsibleField: React.FC<Props> = (props) => {
|
||||
fieldTypes={fieldTypes}
|
||||
fieldSchema={fields.map((field) => ({
|
||||
...field,
|
||||
path: `${path ? `${path}.` : ''}${fieldAffectsData(field) ? field.name : ''}`,
|
||||
path: getFieldPath(path, field),
|
||||
}))}
|
||||
/>
|
||||
</Collapsible>
|
||||
|
||||
@@ -11,6 +11,10 @@ const ConfirmPassword: React.FC = () => {
|
||||
const password = getField('password');
|
||||
|
||||
const validate = useCallback((value) => {
|
||||
if (!value) {
|
||||
return 'This field is required';
|
||||
}
|
||||
|
||||
if (value === password?.value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, {
|
||||
useCallback, useEffect, useState, useReducer,
|
||||
useCallback, useEffect, useState, useReducer, useRef,
|
||||
} from 'react';
|
||||
import equal from 'deep-equal';
|
||||
import qs from 'qs';
|
||||
@@ -22,6 +22,7 @@ import { createRelationMap } from './createRelationMap';
|
||||
import { useDebouncedCallback } from '../../../../hooks/useDebouncedCallback';
|
||||
import { useDocumentInfo } from '../../../utilities/DocumentInfo';
|
||||
import { getFilterOptionsQuery } from '../getFilterOptionsQuery';
|
||||
import wordBoundariesRegex from '../../../../../utilities/wordBoundariesRegex';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -46,6 +47,7 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
width,
|
||||
description,
|
||||
condition,
|
||||
isSortable,
|
||||
} = {},
|
||||
} = props;
|
||||
|
||||
@@ -66,9 +68,11 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
const [lastFullyLoadedRelation, setLastFullyLoadedRelation] = useState(-1);
|
||||
const [lastLoadedPage, setLastLoadedPage] = useState(1);
|
||||
const [errorLoading, setErrorLoading] = useState('');
|
||||
const [optionFilters, setOptionFilters] = useState<{[relation: string]: Where}>();
|
||||
const [optionFilters, setOptionFilters] = useState<{ [relation: string]: Where }>();
|
||||
const [hasLoadedValueOptions, setHasLoadedValueOptions] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [enableWordBoundarySearch, setEnableWordBoundarySearch] = useState(false);
|
||||
const firstRun = useRef(true);
|
||||
|
||||
const memoizedValidate = useCallback((value, validationOptions) => {
|
||||
return validate(value, { ...validationOptions, required });
|
||||
@@ -321,6 +325,30 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
}
|
||||
}, [initialValue, getResults, optionFilters, filterOptions]);
|
||||
|
||||
// Determine if we should switch to word boundary search
|
||||
useEffect(() => {
|
||||
const relations = Array.isArray(relationTo) ? relationTo : [relationTo];
|
||||
const isIdOnly = relations.reduce((idOnly, relation) => {
|
||||
const collection = collections.find((coll) => coll.slug === relation);
|
||||
const fieldToSearch = collection?.admin?.useAsTitle || 'id';
|
||||
return fieldToSearch === 'id' && idOnly;
|
||||
}, true);
|
||||
setEnableWordBoundarySearch(!isIdOnly);
|
||||
}, [relationTo, collections]);
|
||||
|
||||
|
||||
// When relationTo changes, reset relationship options
|
||||
// Note - effect should not run on first run
|
||||
useEffect(() => {
|
||||
if (firstRun.current) {
|
||||
firstRun.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
dispatchOptions({ type: 'CLEAR' });
|
||||
setHasLoadedValueOptions(false);
|
||||
}, [relationTo]);
|
||||
|
||||
const classes = [
|
||||
'field-type',
|
||||
baseClass,
|
||||
@@ -390,6 +418,11 @@ const Relationship: React.FC<Props> = (props) => {
|
||||
disabled={formProcessing}
|
||||
options={options}
|
||||
isMulti={hasMany}
|
||||
isSortable={isSortable}
|
||||
filterOption={enableWordBoundarySearch ? (item, searchFilter) => {
|
||||
const r = wordBoundariesRegex(searchFilter || '');
|
||||
return r.test(item.label);
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
{errorLoading && (
|
||||
|
||||
@@ -25,7 +25,7 @@ const sortOptions = (options: Option[]): Option[] => options.sort((a: Option, b:
|
||||
const optionsReducer = (state: Option[], action: Action): Option[] => {
|
||||
switch (action.type) {
|
||||
case 'CLEAR': {
|
||||
return action.required ? [] : [{ value: 'null', label: 'None' }];
|
||||
return [];
|
||||
}
|
||||
|
||||
case 'ADD': {
|
||||
@@ -51,7 +51,7 @@ const optionsReducer = (state: Option[], action: Action): Option[] => {
|
||||
}
|
||||
return docs;
|
||||
},
|
||||
[]),
|
||||
[]),
|
||||
];
|
||||
|
||||
ids.forEach((id) => {
|
||||
|
||||
@@ -15,7 +15,6 @@ export type Option = {
|
||||
|
||||
type CLEAR = {
|
||||
type: 'CLEAR'
|
||||
required: boolean
|
||||
}
|
||||
|
||||
type ADD = {
|
||||
|
||||
@@ -16,7 +16,7 @@ import enablePlugins from './enablePlugins';
|
||||
import defaultValue from '../../../../../fields/richText/defaultValue';
|
||||
import FieldDescription from '../../FieldDescription';
|
||||
import withHTML from './plugins/withHTML';
|
||||
import { Props, BlurSelectionEditor } from './types';
|
||||
import { Props } from './types';
|
||||
import { RichTextElement, RichTextLeaf } from '../../../../../fields/config/types';
|
||||
import listTypes from './elements/listTypes';
|
||||
import mergeCustomFunctions from './mergeCustomFunctions';
|
||||
@@ -34,7 +34,7 @@ type CustomElement = { type?: string; children: CustomText[] }
|
||||
|
||||
declare module 'slate' {
|
||||
interface CustomTypes {
|
||||
Editor: BaseEditor & ReactEditor & HistoryEditor & BlurSelectionEditor
|
||||
Editor: BaseEditor & ReactEditor & HistoryEditor
|
||||
Element: CustomElement
|
||||
Text: CustomText
|
||||
}
|
||||
@@ -152,18 +152,14 @@ const RichText: React.FC<Props> = (props) => {
|
||||
),
|
||||
);
|
||||
|
||||
CreatedEditor = withHTML(CreatedEditor);
|
||||
|
||||
CreatedEditor = enablePlugins(CreatedEditor, elements);
|
||||
CreatedEditor = enablePlugins(CreatedEditor, leaves);
|
||||
|
||||
CreatedEditor = withHTML(CreatedEditor);
|
||||
|
||||
return CreatedEditor;
|
||||
}, [elements, leaves]);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
editor.blurSelection = editor.selection;
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded) {
|
||||
const mergedElements = mergeCustomFunctions(elements, elementTypes);
|
||||
@@ -238,6 +234,7 @@ const RichText: React.FC<Props> = (props) => {
|
||||
if (Button) {
|
||||
return (
|
||||
<Button
|
||||
fieldProps={props}
|
||||
key={i}
|
||||
path={path}
|
||||
/>
|
||||
@@ -257,6 +254,7 @@ const RichText: React.FC<Props> = (props) => {
|
||||
if (Button) {
|
||||
return (
|
||||
<Button
|
||||
fieldProps={props}
|
||||
key={i}
|
||||
path={path}
|
||||
/>
|
||||
@@ -279,7 +277,6 @@ const RichText: React.FC<Props> = (props) => {
|
||||
placeholder={placeholder}
|
||||
spellCheck
|
||||
readOnly={readOnly}
|
||||
onBlur={onBlur}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) {
|
||||
@@ -289,7 +286,7 @@ const RichText: React.FC<Props> = (props) => {
|
||||
const selectedElement = Node.descendant(editor, editor.selection.anchor.path.slice(0, -1));
|
||||
|
||||
if (SlateElement.isElement(selectedElement)) {
|
||||
// Allow hard enter to "break out" of certain elements
|
||||
// Allow hard enter to "break out" of certain elements
|
||||
if (editor.shouldBreakOutOnEnter(selectedElement)) {
|
||||
event.preventDefault();
|
||||
const selectedLeaf = Node.descendant(editor, editor.selection.anchor.path);
|
||||
|
||||
@@ -24,10 +24,6 @@ const indent = {
|
||||
const handleIndent = useCallback((e, dir) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (editor.blurSelection) {
|
||||
Transforms.select(editor, editor.blurSelection);
|
||||
}
|
||||
|
||||
if (dir === 'left') {
|
||||
Transforms.unwrapNodes(editor, {
|
||||
match: (n) => Element.isElement(n) && [indentType, ...listTypes].includes(n.type),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React, { Fragment, useState } from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { Transforms, Editor, Range } from 'slate';
|
||||
import { useModal } from '@faceless-ui/modal';
|
||||
import ElementButton from '../Button';
|
||||
import { unwrapLink } from './utilities';
|
||||
import LinkIcon from '../../../../../icons/Link';
|
||||
import { EditModal } from './Modal';
|
||||
import { modalSlug as baseModalSlug } from './shared';
|
||||
import isElementActive from '../isActive';
|
||||
import { Fields } from '../../../../Form/types';
|
||||
import buildStateFromSchema from '../../../../Form/buildStateFromSchema';
|
||||
import { useAuth } from '../../../../../utilities/Auth';
|
||||
import { useLocale } from '../../../../../utilities/Locale';
|
||||
import { useConfig } from '../../../../../utilities/Config';
|
||||
import { getBaseFields } from './Modal/baseFields';
|
||||
import { Field } from '../../../../../../../fields/config/types';
|
||||
import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues';
|
||||
|
||||
export const LinkButton = ({ fieldProps }) => {
|
||||
const customFieldSchema = fieldProps?.admin?.link?.fields;
|
||||
|
||||
const modalSlug = `${baseModalSlug}-${fieldProps.path}`;
|
||||
|
||||
const config = useConfig();
|
||||
const editor = useSlate();
|
||||
const { user } = useAuth();
|
||||
const locale = useLocale();
|
||||
const { toggleModal } = useModal();
|
||||
const [renderModal, setRenderModal] = useState(false);
|
||||
const [initialState, setInitialState] = useState<Fields>({});
|
||||
const [fieldSchema] = useState(() => {
|
||||
const fields: Field[] = [
|
||||
...getBaseFields(config),
|
||||
];
|
||||
|
||||
if (customFieldSchema) {
|
||||
fields.push({
|
||||
name: 'fields',
|
||||
type: 'group',
|
||||
admin: {
|
||||
style: {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
borderTop: 0,
|
||||
borderBottom: 0,
|
||||
},
|
||||
},
|
||||
fields: customFieldSchema,
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<ElementButton
|
||||
format="link"
|
||||
onClick={async () => {
|
||||
if (isElementActive(editor, 'link')) {
|
||||
unwrapLink(editor);
|
||||
} else {
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(true);
|
||||
|
||||
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
|
||||
|
||||
if (!isCollapsed) {
|
||||
const data = {
|
||||
text: editor.selection ? Editor.string(editor, editor.selection) : '',
|
||||
};
|
||||
|
||||
const state = await buildStateFromSchema({ fieldSchema, data, user, operation: 'create', locale });
|
||||
setInitialState(state);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LinkIcon />
|
||||
</ElementButton>
|
||||
{renderModal && (
|
||||
<EditModal
|
||||
modalSlug={modalSlug}
|
||||
fieldSchema={fieldSchema}
|
||||
initialState={initialState}
|
||||
close={() => {
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
}}
|
||||
handleModalSubmit={(fields) => {
|
||||
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection);
|
||||
const data = reduceFieldsToValues(fields, true);
|
||||
|
||||
const newLink = {
|
||||
type: 'link',
|
||||
linkType: data.linkType,
|
||||
url: data.url,
|
||||
doc: data.doc,
|
||||
newTab: data.newTab,
|
||||
fields: data.fields,
|
||||
children: [],
|
||||
};
|
||||
|
||||
if (isCollapsed || !editor.selection) {
|
||||
// If selection anchor and focus are the same,
|
||||
// Just inject a new node with children already set
|
||||
Transforms.insertNodes(editor, {
|
||||
...newLink,
|
||||
children: [{ text: String(data.text) }],
|
||||
});
|
||||
} else if (editor.selection) {
|
||||
// Otherwise we need to wrap the selected node in a link,
|
||||
// Delete its old text,
|
||||
// Move the selection one position forward into the link,
|
||||
// And insert the text back into the new link
|
||||
Transforms.wrapNodes(editor, newLink, { split: true });
|
||||
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'word' });
|
||||
Transforms.move(editor, { distance: 1, unit: 'offset' });
|
||||
Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path });
|
||||
}
|
||||
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
import React, { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import { ReactEditor, useSlate } from 'slate-react';
|
||||
import { Transforms, Node, Editor } from 'slate';
|
||||
import { useModal } from '@faceless-ui/modal';
|
||||
import { unwrapLink } from './utilities';
|
||||
import Popup from '../../../../../elements/Popup';
|
||||
import { EditModal } from './Modal';
|
||||
import { modalSlug as baseModalSlug } from './shared';
|
||||
import { Fields } from '../../../../Form/types';
|
||||
import buildStateFromSchema from '../../../../Form/buildStateFromSchema';
|
||||
import { useAuth } from '../../../../../utilities/Auth';
|
||||
import { useLocale } from '../../../../../utilities/Locale';
|
||||
import { useConfig } from '../../../../../utilities/Config';
|
||||
import { getBaseFields } from './Modal/baseFields';
|
||||
import { Field } from '../../../../../../../fields/config/types';
|
||||
import reduceFieldsToValues from '../../../../Form/reduceFieldsToValues';
|
||||
import deepCopyObject from '../../../../../../../utilities/deepCopyObject';
|
||||
import Button from '../../../../../elements/Button';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
const baseClass = 'rich-text-link';
|
||||
|
||||
// TODO: Multiple modal windows stacked go boom (rip). Edit Upload in fields -> rich text
|
||||
|
||||
export const LinkElement = ({ attributes, children, element, editorRef, fieldProps }) => {
|
||||
const customFieldSchema = fieldProps?.admin?.link?.fields;
|
||||
|
||||
const editor = useSlate();
|
||||
const config = useConfig();
|
||||
const { user } = useAuth();
|
||||
const locale = useLocale();
|
||||
const { openModal, toggleModal } = useModal();
|
||||
const [renderModal, setRenderModal] = useState(false);
|
||||
const [renderPopup, setRenderPopup] = useState(false);
|
||||
const [initialState, setInitialState] = useState<Fields>({});
|
||||
const [fieldSchema] = useState(() => {
|
||||
const fields: Field[] = [
|
||||
...getBaseFields(config),
|
||||
];
|
||||
|
||||
if (customFieldSchema) {
|
||||
fields.push({
|
||||
name: 'fields',
|
||||
type: 'group',
|
||||
admin: {
|
||||
style: {
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
borderTop: 0,
|
||||
borderBottom: 0,
|
||||
},
|
||||
},
|
||||
fields: customFieldSchema,
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
});
|
||||
|
||||
const modalSlug = `${baseModalSlug}-${fieldProps.path}`;
|
||||
|
||||
const handleTogglePopup = useCallback((render) => {
|
||||
if (!render) {
|
||||
setRenderPopup(render);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const awaitInitialState = async () => {
|
||||
const data = {
|
||||
text: Node.string(element),
|
||||
linkType: element.linkType,
|
||||
url: element.url,
|
||||
doc: element.doc,
|
||||
newTab: element.newTab,
|
||||
fields: deepCopyObject(element.fields),
|
||||
};
|
||||
|
||||
const state = await buildStateFromSchema({ fieldSchema, data, user, operation: 'update', locale });
|
||||
setInitialState(state);
|
||||
};
|
||||
|
||||
awaitInitialState();
|
||||
}, [renderModal, element, fieldSchema, user, locale]);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={baseClass}
|
||||
{...attributes}
|
||||
>
|
||||
<span
|
||||
style={{ userSelect: 'none' }}
|
||||
contentEditable={false}
|
||||
>
|
||||
{renderModal && (
|
||||
<EditModal
|
||||
modalSlug={modalSlug}
|
||||
fieldSchema={fieldSchema}
|
||||
close={() => {
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
}}
|
||||
handleModalSubmit={(fields) => {
|
||||
toggleModal(modalSlug);
|
||||
setRenderModal(false);
|
||||
|
||||
const data = reduceFieldsToValues(fields, true);
|
||||
|
||||
const [, parentPath] = Editor.above(editor);
|
||||
|
||||
const newNode: Record<string, unknown> = {
|
||||
newTab: data.newTab,
|
||||
url: data.url,
|
||||
linkType: data.linkType,
|
||||
doc: data.doc,
|
||||
};
|
||||
|
||||
if (customFieldSchema) {
|
||||
newNode.fields = data.fields;
|
||||
}
|
||||
|
||||
Transforms.setNodes(
|
||||
editor,
|
||||
newNode,
|
||||
{ at: parentPath },
|
||||
);
|
||||
|
||||
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' });
|
||||
Transforms.move(editor, { distance: 1, unit: 'offset' });
|
||||
Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path });
|
||||
|
||||
ReactEditor.focus(editor);
|
||||
}}
|
||||
initialState={initialState}
|
||||
/>
|
||||
)}
|
||||
<Popup
|
||||
buttonType="none"
|
||||
size="small"
|
||||
forceOpen={renderPopup}
|
||||
onToggleOpen={handleTogglePopup}
|
||||
horizontalAlign="left"
|
||||
verticalAlign="bottom"
|
||||
boundingRef={editorRef}
|
||||
render={() => (
|
||||
<div className={`${baseClass}__popup`}>
|
||||
{element.linkType === 'internal' && element.doc?.relationTo && element.doc?.value && (
|
||||
<Fragment>
|
||||
Linked to
|
||||
<a
|
||||
className={`${baseClass}__link-label`}
|
||||
href={`${config.routes.admin}/collections/${element.doc.relationTo}/${element.doc.value}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{config.collections.find(({ slug }) => slug === element.doc.relationTo)?.labels?.singular}
|
||||
</a>
|
||||
</Fragment>
|
||||
)}
|
||||
{(element.linkType === 'custom' || !element.linkType) && (
|
||||
<a
|
||||
className={`${baseClass}__link-label`}
|
||||
href={element.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{element.url}
|
||||
</a>
|
||||
)}
|
||||
<Button
|
||||
className={`${baseClass}__link-edit`}
|
||||
icon="edit"
|
||||
round
|
||||
buttonStyle="icon-label"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setRenderPopup(false);
|
||||
openModal(modalSlug);
|
||||
setRenderModal(true);
|
||||
}}
|
||||
tooltip="Edit"
|
||||
/>
|
||||
<Button
|
||||
className={`${baseClass}__link-close`}
|
||||
icon="x"
|
||||
round
|
||||
buttonStyle="icon-label"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
unwrapLink(editor);
|
||||
}}
|
||||
tooltip="Remove"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className={[
|
||||
`${baseClass}__button`,
|
||||
].filter(Boolean).join(' ')}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') setRenderPopup(true); }}
|
||||
onClick={() => setRenderPopup(true)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Config } from '../../../../../../../../config/types';
|
||||
import { Field } from '../../../../../../../../fields/config/types';
|
||||
|
||||
export const getBaseFields = (config: Config): Field[] => [
|
||||
{
|
||||
name: 'text',
|
||||
label: 'Text to display',
|
||||
type: 'text',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'linkType',
|
||||
label: 'Link Type',
|
||||
type: 'radio',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Choose between entering a custom text URL or linking to another document.',
|
||||
},
|
||||
defaultValue: 'custom',
|
||||
options: [
|
||||
{
|
||||
label: 'Custom URL',
|
||||
value: 'custom',
|
||||
},
|
||||
{
|
||||
label: 'Internal Link',
|
||||
value: 'internal',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'url',
|
||||
label: 'Enter a URL',
|
||||
type: 'text',
|
||||
required: true,
|
||||
admin: {
|
||||
condition: ({ linkType, url }) => {
|
||||
return (typeof linkType === 'undefined' && url) || linkType === 'custom';
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'doc',
|
||||
label: 'Choose a document to link to',
|
||||
type: 'relationship',
|
||||
required: true,
|
||||
relationTo: config.collections.map(({ slug }) => slug),
|
||||
admin: {
|
||||
condition: ({ linkType }) => {
|
||||
return linkType === 'internal';
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'newTab',
|
||||
label: 'Open in new tab',
|
||||
type: 'checkbox',
|
||||
},
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user