diff --git a/.eslintrc.js b/.eslintrc.js index b63a55c1f9..1ed0874712 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', } }, { diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md index 3963f15f79..f82aab2335 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.md +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md @@ -1,7 +1,7 @@ --- name: Bug Report about: Create a bug report for Payload -labels: 'bug' +labels: 'possible-bug' --- # Bug Report diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 565db30d1a..a47f93d93e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,10 @@ name: build -on: [push, pull_request] +on: + pull_request: + types: [opened, reopened, edited, synchronize] + push: + branches: ['master'] jobs: build_yarn: diff --git a/CHANGELOG.md b/CHANGELOG.md index ba62f8bb84..3bad8bba74 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/components/elements.ts b/components/elements.ts index 81cdbc27db..1de9c31e64 100644 --- a/components/elements.ts +++ b/components/elements.ts @@ -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'; diff --git a/docs/access-control/collections.mdx b/docs/access-control/collections.mdx index d1cecc32d8..aeec53e995 100644 --- a/docs/access-control/collections.mdx +++ b/docs/access-control/collections.mdx @@ -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; diff --git a/docs/access-control/fields.mdx b/docs/access-control/fields.mdx index fe3d4167f3..3a5b236143 100644 --- a/docs/access-control/fields.mdx +++ b/docs/access-control/fields.mdx @@ -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 diff --git a/docs/access-control/globals.mdx b/docs/access-control/globals.mdx index 3cd6fc4dce..bdb73b9fad 100644 --- a/docs/access-control/globals.mdx +++ b/docs/access-control/globals.mdx @@ -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 diff --git a/docs/access-control/overview.mdx b/docs/access-control/overview.mdx index 07edc2d2f9..0ffa9e725e 100644 --- a/docs/access-control/overview.mdx +++ b/docs/access-control/overview.mdx @@ -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 diff --git a/docs/admin/components.mdx b/docs/admin/components.mdx index 94d7f95794..6e82397e8a 100644 --- a/docs/admin/components.mdx +++ b/docs/admin/components.mdx @@ -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 = ({ path }) => { // highlight-start - const { value, setValue } = useField({ path }); + const { value, setValue } = useField({ path }) // highlight-end - return ( - setValue(e.target.value)} - value={value} - /> - ) + return 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 ( - The fee is ${ amount * feePercentage / 100 } + The fee is ${(amount * feePercentage) / 100} ); } }; @@ -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 ( { trans[locale] } - ) -} + ); +}; ``` diff --git a/docs/admin/customizing-css.mdx b/docs/admin/customizing-css.mdx index f8aad29358..926307655d 100644 --- a/docs/admin/customizing-css.mdx +++ b/docs/admin/customizing-css.mdx @@ -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 diff --git a/docs/admin/overview.mdx b/docs/admin/overview.mdx index c3f0843ed7..cdc067097f 100644 --- a/docs/admin/overview.mdx +++ b/docs/admin/overview.mdx @@ -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. diff --git a/docs/admin/webpack.mdx b/docs/admin/webpack.mdx index 95983bea27..b48060b87d 100644 --- a/docs/admin/webpack.mdx +++ b/docs/admin/webpack.mdx @@ -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

`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; ``` diff --git a/docs/authentication/config.mdx b/docs/authentication/config.mdx index 5a84457630..aff7fa46a7 100644 --- a/docs/authentication/config.mdx +++ b/docs/authentication/config.mdx @@ -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 } } -} +}; ``` @@ -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 } } -} +}; ``` @@ -182,7 +187,7 @@ Similarly to the above `generateEmailHTML`, you can also customize the subject o Example: -```js +```ts { slug: 'customers', auth: { diff --git a/docs/authentication/operations.mdx b/docs/authentication/operations.mdx index b3cfb813e8..81735fda1b 100644 --- a/docs/authentication/operations.mdx +++ b/docs/authentication/operations.mdx @@ -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 -}) +}); ``` @@ -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") } diff --git a/docs/authentication/overview.mdx b/docs/authentication/overview.mdx index aad003d787..a5997a4a40 100644 --- a/docs/authentication/overview.mdx +++ b/docs/authentication/overview.mdx @@ -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}` diff --git a/docs/authentication/using-middleware.mdx b/docs/authentication/using-middleware.mdx index 1adbe4f3e8..e26b924e04 100644 --- a/docs/authentication/using-middleware.mdx +++ b/docs/authentication/using-middleware.mdx @@ -15,7 +15,7 @@ This approach has a ton of benefits - it's great for isolation of concerns and l Example in `server.js`: -```js +```ts import express from 'express'; import payload from 'payload'; diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 44bb5a6431..76376a0752 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -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. diff --git a/docs/configuration/globals.mdx b/docs/configuration/globals.mdx index dcd53dfa39..6bd5b9487c 100644 --- a/docs/configuration/globals.mdx +++ b/docs/configuration/globals.mdx @@ -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. diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index 6348f0d016..a2f7779d93 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -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. - Note:
- 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. + Note:
+ 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.
### 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 + } + } } ``` - 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. ##### 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, }) ``` diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index afb0fe87f2..0f1523fc50 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -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. diff --git a/docs/email/overview.mdx b/docs/email/overview.mdx index a19199fbb6..420323919a 100644 --- a/docs/email/overview.mdx +++ b/docs/email/overview.mdx @@ -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' } // ... +}) ``` @@ -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', diff --git a/docs/fields/array.mdx b/docs/fields/array.mdx index 00392da0bc..ec934c5587 100644 --- a/docs/fields/array.mdx +++ b/docs/fields/array.mdx @@ -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 ] } ] -} - +}; ``` diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx index 7944417a74..be6baecb33 100644 --- a/docs/fields/blocks.mdx +++ b/docs/fields/blocks.mdx @@ -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'; ``` diff --git a/docs/fields/checkbox.mdx b/docs/fields/checkbox.mdx index d7b3703b2b..1e419676b2 100644 --- a/docs/fields/checkbox.mdx +++ b/docs/fields/checkbox.mdx @@ -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, } ] -} - +}; ``` diff --git a/docs/fields/code.mdx b/docs/fields/code.mdx index ab9e3ed4cb..f7985523af 100644 --- a/docs/fields/code.mdx +++ b/docs/fields/code.mdx @@ -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 } } ] -} - +}; ``` diff --git a/docs/fields/collapsible.mdx b/docs/fields/collapsible.mdx index e33e1fbea3..e990f0def7 100644 --- a/docs/fields/collapsible.mdx +++ b/docs/fields/collapsible.mdx @@ -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 ], } ] -} - +}; ``` diff --git a/docs/fields/date.mdx b/docs/fields/date.mdx index 7ccf812c1b..60057c7b6a 100644 --- a/docs/fields/date.mdx +++ b/docs/fields/date.mdx @@ -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 } } ] -} - +}; ``` diff --git a/docs/fields/email.mdx b/docs/fields/email.mdx index 87d4ea677e..b2d895259e 100644 --- a/docs/fields/email.mdx +++ b/docs/fields/email.mdx @@ -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, } ] -} - +}; ``` diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx index 11946adf58..aa90f6fff1 100644 --- a/docs/fields/group.mdx +++ b/docs/fields/group.mdx @@ -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. ], } ] -} - +}; ``` diff --git a/docs/fields/number.mdx b/docs/fields/number.mdx index 0aa80a3f6d..2023080f24 100644 --- a/docs/fields/number.mdx +++ b/docs/fields/number.mdx @@ -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. } } ] -} - +}; ``` diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 30d6f316d0..9442f7f359 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -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 +}; ``` @@ -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'; ``` diff --git a/docs/fields/point.mdx b/docs/fields/point.mdx index 74fe7759c9..1fa134a8a4 100644 --- a/docs/fields/point.mdx +++ b/docs/fields/point.mdx @@ -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 diff --git a/docs/fields/radio.mdx b/docs/fields/radio.mdx index a82a850664..1918016bd7 100644 --- a/docs/fields/radio.mdx +++ b/docs/fields/radio.mdx @@ -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: [ { diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 60bdb8c9ac..a64122d077 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -45,6 +45,14 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma The Depth parameter can be used to automatically populate related documents that are returned by the API. +### 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: [ diff --git a/docs/fields/rich-text.mdx b/docs/fields/rich-text.mdx index ec27fb3c59..bfee4e2d27 100644 --- a/docs/fields/rich-text.mdx +++ b/docs/fields/rich-text.mdx @@ -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 fields](https://payloadcms.com/images/fields/richText/rte-link-fields-modal.jpg) +*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, diff --git a/docs/fields/row.mdx b/docs/fields/row.mdx index b06d439c21..9823aeacd4 100644 --- a/docs/fields/row.mdx +++ b/docs/fields/row.mdx @@ -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: [ { diff --git a/docs/fields/select.mdx b/docs/fields/select.mdx index ffdf2655d3..9df50056ca 100644 --- a/docs/fields/select.mdx +++ b/docs/fields/select.mdx @@ -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: [ { diff --git a/docs/fields/tabs.mdx b/docs/fields/tabs.mdx index 95ae560272..eb1a5421a5 100644 --- a/docs/fields/tabs.mdx +++ b/docs/fields/tabs.mdx @@ -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: [ { diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index af6345337a..a5bf0770a2 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -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: [ { diff --git a/docs/fields/textarea.mdx b/docs/fields/textarea.mdx index 2814f43b1e..bbc0eccb87 100644 --- a/docs/fields/textarea.mdx +++ b/docs/fields/textarea.mdx @@ -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: [ { diff --git a/docs/fields/ui.mdx b/docs/fields/ui.mdx index 9ee48eda66..89737a40f7 100644 --- a/docs/fields/ui.mdx +++ b/docs/fields/ui.mdx @@ -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: [ { diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index dfcc2ed75f..90e7a16f0f 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -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: [ { diff --git a/docs/getting-started/concepts.mdx b/docs/getting-started/concepts.mdx index 3d3697dc09..129f5908dc 100644 --- a/docs/getting-started/concepts.mdx +++ b/docs/getting-started/concepts.mdx @@ -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. -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 diff --git a/docs/graphql/extending.mdx b/docs/graphql/extending.mdx index d9b7da6133..e6c723e769 100644 --- a/docs/graphql/extending.mdx +++ b/docs/graphql/extending.mdx @@ -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'; diff --git a/docs/graphql/overview.mdx b/docs/graphql/overview.mdx index e83eb1fb60..9b626bab25 100644 --- a/docs/graphql/overview.mdx +++ b/docs/graphql/overview.mdx @@ -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: [ ... diff --git a/docs/hooks/collections.mdx b/docs/hooks/collections.mdx index e74bf95569..9cac9ed8ab 100644 --- a/docs/hooks/collections.mdx +++ b/docs/hooks/collections.mdx @@ -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... -} ``` diff --git a/docs/hooks/fields.mdx b/docs/hooks/fields.mdx index 22480e29c8..6dbec80125 100644 --- a/docs/hooks/fields.mdx +++ b/docs/hooks/fields.mdx @@ -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: diff --git a/docs/hooks/globals.mdx b/docs/hooks/globals.mdx index 1e4824139c..034d00db9b 100644 --- a/docs/hooks/globals.mdx +++ b/docs/hooks/globals.mdx @@ -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... -} ``` diff --git a/docs/local-api/overview.mdx b/docs/local-api/overview.mdx index 93240fc62c..cd9fbe15c9 100644 --- a/docs/local-api/overview.mdx +++ b/docs/local-api/overview.mdx @@ -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({ + collection: 'posts', + + // Data will now be typed as Post and give you type hints + data: { + title: 'my title', + description: 'my description', + }, +}) +``` diff --git a/docs/plugins/overview.mdx b/docs/plugins/overview.mdx index b7110d023e..180d19f755 100644 --- a/docs/plugins/overview.mdx +++ b/docs/plugins/overview.mdx @@ -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. diff --git a/docs/production/deployment.mdx b/docs/production/deployment.mdx index 91ef84f9d4..940bb9818f 100644 --- a/docs/production/deployment.mdx +++ b/docs/production/deployment.mdx @@ -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": { diff --git a/docs/queries/overview.mdx b/docs/queries/overview.mdx index e6c65893de..c8bf326ff7 100644 --- a/docs/queries/overview.mdx +++ b/docs/queries/overview.mdx @@ -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: [ { diff --git a/docs/queries/pagination.mdx b/docs/queries/pagination.mdx index 665cfe9751..ef9cbd2999 100644 --- a/docs/queries/pagination.mdx +++ b/docs/queries/pagination.mdx @@ -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": [ diff --git a/docs/rest-api/overview.mdx b/docs/rest-api/overview.mdx index 66026236b2..57d8dad1db 100644 --- a/docs/rest-api/overview.mdx +++ b/docs/rest-api/overview.mdx @@ -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 diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 3a8bc4a52a..e31aed7a38 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -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 }, }; ``` diff --git a/docs/versions/autosave.mdx b/docs/versions/autosave.mdx index 3717a752d5..f2bc2b7d6e 100644 --- a/docs/versions/autosave.mdx +++ b/docs/versions/autosave.mdx @@ -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 }) => { diff --git a/docs/versions/drafts.mdx b/docs/versions/drafts.mdx index 0f92e4fa14..7cffd18f07 100644 --- a/docs/versions/drafts.mdx +++ b/docs/versions/drafts.mdx @@ -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 }) => { diff --git a/docs/versions/overview.mdx b/docs/versions/overview.mdx index 0adeaf92ba..13872c0ec9 100644 --- a/docs/versions/overview.mdx +++ b/docs/versions/overview.mdx @@ -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 diff --git a/package.json b/package.json index 87b7347838..5d52716e8f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/admin/api.ts b/src/admin/api.ts index a55f205398..1750d78f0d 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -34,6 +34,20 @@ export const requests = { return fetch(url, formattedOptions); }, + patch: (url: string, options: RequestInit = { headers: {} }): Promise => { + 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 => { const headers = options && options.headers ? { ...options.headers } : {}; return fetch(url, { diff --git a/src/admin/components/Routes.tsx b/src/admin/components/Routes.tsx index 2e981b9cc0..918813da66 100644 --- a/src/admin/components/Routes.tsx +++ b/src/admin/components/Routes.tsx @@ -208,6 +208,7 @@ const Routes = () => { if (permissions?.collections?.[collection.slug]?.read?.permission) { return ( diff --git a/src/admin/components/elements/Autosave/index.tsx b/src/admin/components/elements/Autosave/index.tsx index 47414ed5f8..2c31a54a88 100644 --- a/src/admin/components/elements/Autosave/index.tsx +++ b/src/admin/components/elements/Autosave/index.tsx @@ -77,7 +77,7 @@ const Autosave: React.FC = ({ 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) { diff --git a/src/admin/components/elements/DatePicker/DatePicker.tsx b/src/admin/components/elements/DatePicker/DatePicker.tsx index d45624534c..be0f18d4ac 100644 --- a/src/admin/components/elements/DatePicker/DatePicker.tsx +++ b/src/admin/components/elements/DatePicker/DatePicker.tsx @@ -31,6 +31,7 @@ const DateTime: React.FC = (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) => { showPopperArrow: false, selected: value && new Date(value), customInputRef: 'ref', + showMonthYearPicker: pickerAppearance === 'monthOnly', }; const classes = [ diff --git a/src/admin/components/elements/DatePicker/types.ts b/src/admin/components/elements/DatePicker/types.ts index c7ba02d0f0..13af88cf0e 100644 --- a/src/admin/components/elements/DatePicker/types.ts +++ b/src/admin/components/elements/DatePicker/types.ts @@ -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 diff --git a/src/admin/components/elements/DeleteDocument/index.tsx b/src/admin/components/elements/DeleteDocument/index.tsx index f9873eb9c5..b609196a3d 100644 --- a/src/admin/components/elements/DeleteDocument/index.tsx +++ b/src/admin/components/elements/DeleteDocument/index.tsx @@ -33,7 +33,7 @@ const DeleteDocument: React.FC = (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) => { 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) => { 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) => { onClick={(e) => { e.preventDefault(); setDeleting(false); - toggle(modalSlug); + toggleModal(modalSlug); }} > Delete @@ -110,7 +110,7 @@ const DeleteDocument: React.FC = (props) => { id="confirm-cancel" buttonStyle="secondary" type="button" - onClick={deleting ? undefined : () => toggle(modalSlug)} + onClick={deleting ? undefined : () => toggleModal(modalSlug)} > Cancel diff --git a/src/admin/components/elements/DuplicateDocument/index.scss b/src/admin/components/elements/DuplicateDocument/index.scss index e69de29bb2..a2c98b20e6 100644 --- a/src/admin/components/elements/DuplicateDocument/index.scss +++ b/src/admin/components/elements/DuplicateDocument/index.scss @@ -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; + } + + +} diff --git a/src/admin/components/elements/DuplicateDocument/index.tsx b/src/admin/components/elements/DuplicateDocument/index.tsx index 5c59984e2a..b1cc135135 100644 --- a/src/admin/components/elements/DuplicateDocument/index.tsx +++ b/src/admin/components/elements/DuplicateDocument/index.tsx @@ -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 = ({ slug }) => { +const Duplicate: React.FC = ({ 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(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 => { + 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 ( - + + + {modified && hasClicked && ( + + +

Confirm duplicate

+

+ You have unsaved changes. Would you like to continue to duplicate? +

+ + +
+
+ )} +
); }; diff --git a/src/admin/components/elements/DuplicateDocument/types.ts b/src/admin/components/elements/DuplicateDocument/types.ts index ac7846166d..ef5ca4f490 100644 --- a/src/admin/components/elements/DuplicateDocument/types.ts +++ b/src/admin/components/elements/DuplicateDocument/types.ts @@ -1,3 +1,7 @@ +import { SanitizedCollectionConfig } from '../../../../collections/config/types'; + export type Props = { - slug: string, + slug: string + collection: SanitizedCollectionConfig + id: string } diff --git a/src/admin/components/elements/GenerateConfirmation/index.tsx b/src/admin/components/elements/GenerateConfirmation/index.tsx index 2237e781d5..2b3797c37d 100644 --- a/src/admin/components/elements/GenerateConfirmation/index.tsx +++ b/src/admin/components/elements/GenerateConfirmation/index.tsx @@ -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) => { 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) => { size="small" buttonStyle="secondary" onClick={() => { - toggle(modalSlug); + toggleModal(modalSlug); }} > Generate new API key @@ -57,7 +59,7 @@ const GenerateConfirmation: React.FC = (props) => { buttonStyle="secondary" type="button" onClick={() => { - toggle(modalSlug); + toggleModal(modalSlug); }} > Cancel diff --git a/src/admin/components/elements/Popup/index.tsx b/src/admin/components/elements/Popup/index.tsx index c283206c50..792730487e 100644 --- a/src/admin/components/elements/Popup/index.tsx +++ b/src/admin/components/elements/Popup/index.tsx @@ -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) => { 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) => { 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) => { `${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) => { >
) => { + // 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 = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + const classes = [ + props.className, + !props.isDisabled && 'draggable', + ].filter(Boolean).join(' '); + + return ( + + ); + }, +); + + +const SortableMultiValueLabel = SortableHandle((props) => ); + +const SortableSelect = SortableContainer(Select) as React.ComponentClass & SortableContainerProps>; + const ReactSelect: React.FC = (props) => { const { className, @@ -16,6 +59,9 @@ const ReactSelect: React.FC = (props) => { placeholder, isSearchable = true, isClearable, + isMulti, + isSortable, + filterOption = undefined, } = props; const classes = [ @@ -24,6 +70,50 @@ const ReactSelect: React.FC = (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 ( + 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 ( { - const { value } = e.target; - - if (value && error) { - setError(false); - } - - const path = ReactEditor.findPath(editor, element); - - Transforms.setNodes( - editor, - { url: value }, - { at: path }, - ); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - close(); - } - }} - /> -
- - - )} - /> - - - - ); -}; - -const LinkButton = () => { - const editor = useSlate(); - - return ( - wrapLink(editor)} - > - - - ); -}; +import { withLinks } from './utilities'; +import { LinkButton } from './Button'; +import { LinkElement } from './Element'; const link = { Button: LinkButton, - Element: Link, + Element: LinkElement, plugins: [ withLinks, ], diff --git a/src/admin/components/forms/field-types/RichText/elements/link/shared.ts b/src/admin/components/forms/field-types/RichText/elements/link/shared.ts new file mode 100644 index 0000000000..e73642bb33 --- /dev/null +++ b/src/admin/components/forms/field-types/RichText/elements/link/shared.ts @@ -0,0 +1 @@ +export const modalSlug = 'rich-text-link-modal'; diff --git a/src/admin/components/forms/field-types/RichText/elements/link/utilities.tsx b/src/admin/components/forms/field-types/RichText/elements/link/utilities.tsx index f6f2f5b1e8..11fe101535 100644 --- a/src/admin/components/forms/field-types/RichText/elements/link/utilities.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/link/utilities.tsx @@ -1,37 +1,25 @@ import { Editor, Transforms, Range, Element } from 'slate'; -import isElementActive from '../isActive'; export const unwrapLink = (editor: Editor): void => { Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === 'link' }); }; -export const wrapLink = (editor: Editor, url?: string, newTab?: boolean): void => { - const { selection, blurSelection } = editor; +export const wrapLink = (editor: Editor): void => { + const { selection } = editor; + const isCollapsed = selection && Range.isCollapsed(selection); - if (blurSelection) { - Transforms.select(editor, blurSelection); - } + const link = { + type: 'link', + url: undefined, + newTab: false, + children: isCollapsed ? [{ text: '' }] : [], + }; - if (isElementActive(editor, 'link')) { - unwrapLink(editor); + if (isCollapsed) { + Transforms.insertNodes(editor, link); } else { - const selectionToUse = selection || blurSelection; - - const isCollapsed = selectionToUse && Range.isCollapsed(selectionToUse); - - const link = { - type: 'link', - url, - newTab, - children: isCollapsed ? [{ text: url }] : [], - }; - - if (isCollapsed) { - Transforms.insertNodes(editor, link); - } else { - Transforms.wrapNodes(editor, link, { split: true }); - Transforms.collapse(editor, { edge: 'end' }); - } + Transforms.wrapNodes(editor, link, { split: true }); + Transforms.collapse(editor, { edge: 'end' }); } }; diff --git a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx index cc169b433e..59d470f71f 100644 --- a/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx +++ b/src/admin/components/forms/field-types/RichText/elements/relationship/Button/index.tsx @@ -1,6 +1,5 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react'; import { Modal, useModal } from '@faceless-ui/modal'; -import { Transforms } from 'slate'; import { ReactEditor, useSlate } from 'slate-react'; import { useConfig } from '../../../../../../utilities/Config'; import ElementButton from '../../Button'; @@ -32,17 +31,13 @@ const insertRelationship = (editor, { value, relationTo }) => { ], }; - if (editor.blurSelection) { - Transforms.select(editor, editor.blurSelection); - } - injectVoidElement(editor, relationship); ReactEditor.focus(editor); }; -const RelationshipButton: React.FC<{path: string}> = ({ path }) => { - const { open, closeAll } = useModal(); +const RelationshipButton: React.FC<{ path: string }> = ({ path }) => { + const { toggleModal } = useModal(); const editor = useSlate(); const { serverURL, routes: { api }, collections } = useConfig(); const [renderModal, setRenderModal] = useState(false); @@ -57,16 +52,16 @@ const RelationshipButton: React.FC<{path: string}> = ({ path }) => { const json = await res.json(); insertRelationship(editor, { value: { id: json.id }, relationTo }); - closeAll(); + toggleModal(modalSlug); setRenderModal(false); setLoading(false); - }, [editor, closeAll, api, serverURL]); + }, [editor, toggleModal, modalSlug, api, serverURL]); useEffect(() => { if (renderModal) { - open(modalSlug); + toggleModal(modalSlug); } - }, [renderModal, open, modalSlug]); + }, [renderModal, toggleModal, modalSlug]); if (!hasEnabledCollections) return null; @@ -90,7 +85,7 @@ const RelationshipButton: React.FC<{path: string}> = ({ path }) => {
{description && ( diff --git a/src/admin/components/forms/field-types/Upload/Input.tsx b/src/admin/components/forms/field-types/Upload/Input.tsx index 807ebce089..0201917534 100644 --- a/src/admin/components/forms/field-types/Upload/Input.tsx +++ b/src/admin/components/forms/field-types/Upload/Input.tsx @@ -58,7 +58,7 @@ const UploadInput: React.FC = (props) => { filterOptions, } = props; - const { toggle } = useModal(); + const { toggleModal } = useModal(); const addModalSlug = `${path}-add`; const selectExistingModalSlug = `${path}-select-existing`; @@ -131,7 +131,7 @@ const UploadInput: React.FC = (props) => { diff --git a/src/admin/components/views/collections/Edit/Auth/index.tsx b/src/admin/components/views/collections/Edit/Auth/index.tsx index d0032d4f09..4dc23c03a4 100644 --- a/src/admin/components/views/collections/Edit/Auth/index.tsx +++ b/src/admin/components/views/collections/Edit/Auth/index.tsx @@ -18,17 +18,11 @@ const baseClass = 'auth-fields'; const Auth: React.FC = (props) => { const { useAPIKey, requirePassword, verify, collection: { slug }, collection, email, operation } = props; const [changingPassword, setChangingPassword] = useState(requirePassword); - const { getField } = useWatchForm(); + const { getField, dispatchFields } = useWatchForm(); const modified = useFormModified(); const enableAPIKey = getField('enableAPIKey'); - useEffect(() => { - if (!modified) { - setChangingPassword(false); - } - }, [modified]); - const { serverURL, routes: { @@ -36,6 +30,15 @@ const Auth: React.FC = (props) => { }, } = useConfig(); + const handleChangePassword = useCallback(async (state: boolean) => { + if (!state) { + dispatchFields({ type: 'REMOVE', path: 'password' }); + dispatchFields({ type: 'REMOVE', path: 'confirm-password' }); + } + + setChangingPassword(state); + }, [dispatchFields]); + const unlock = useCallback(async () => { const url = `${serverURL}${api}/${slug}/unlock`; const response = await fetch(url, { @@ -55,6 +58,12 @@ const Auth: React.FC = (props) => { } }, [serverURL, api, slug, email]); + useEffect(() => { + if (!modified) { + setChangingPassword(false); + } + }, [modified]); + if (collection.auth.disableLocalStrategy) { return null; } @@ -80,7 +89,7 @@ const Auth: React.FC = (props) => { @@ -91,7 +100,7 @@ const Auth: React.FC = (props) => { diff --git a/src/admin/components/views/collections/Edit/Default.tsx b/src/admin/components/views/collections/Edit/Default.tsx index 2e82e0ae08..94b5acf5d0 100644 --- a/src/admin/components/views/collections/Edit/Default.tsx +++ b/src/admin/components/views/collections/Edit/Default.tsx @@ -81,7 +81,7 @@ const DefaultEditView: React.FC = (props) => { = (props) => { Create New - {!disableDuplicate && ( -
  • + {!disableDuplicate && isEditing && ( +
  • + +
  • )} )} diff --git a/src/admin/components/views/collections/Edit/index.tsx b/src/admin/components/views/collections/Edit/index.tsx index 2bd498bd35..81614a7e97 100644 --- a/src/admin/components/views/collections/Edit/index.tsx +++ b/src/admin/components/views/collections/Edit/index.tsx @@ -36,6 +36,7 @@ const EditView: React.FC = (props) => { const [fields] = useState(() => formatFields(incomingCollection, isEditing)); const [collection] = useState(() => ({ ...incomingCollection, fields })); + const [redirect, setRedirect] = useState(); const locale = useLocale(); const { serverURL, routes: { admin, api } } = useConfig(); @@ -51,12 +52,12 @@ const EditView: React.FC = (props) => { const onSave = useCallback(async (json: any) => { getVersions(); if (!isEditing) { - history.push(`${admin}/collections/${collection.slug}/${json?.doc?.id}`); + setRedirect(`${admin}/collections/${collection.slug}/${json?.doc?.id}`); } else { const state = await buildStateFromSchema({ fieldSchema: collection.fields, data: json.doc, user, id, operation: 'update', locale }); setInitialState(state); } - }, [admin, collection, history, isEditing, getVersions, user, id, locale]); + }, [admin, collection, isEditing, getVersions, user, id, locale]); const [{ data, isLoading: isLoadingDocument, isError }] = usePayloadAPI( (isEditing ? `${serverURL}${api}/${slug}/${id}` : null), @@ -111,6 +112,12 @@ const EditView: React.FC = (props) => { awaitInitialState(); }, [dataToRender, fields, isEditing, id, user, locale, isLoadingDocument, preferencesKey, getPreference]); + useEffect(() => { + if (redirect) { + history.push(redirect); + } + }, [history, redirect]); + if (isError) { return ( diff --git a/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx index b98c880417..ba88508c08 100644 --- a/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx +++ b/src/admin/components/views/collections/List/Cell/field-types/Relationship/index.tsx @@ -17,7 +17,7 @@ const RelationshipCell = (props) => { const { getRelationships, documents } = useListRelationships(); const [hasRequested, setHasRequested] = useState(false); - const isAboveViewport = entry?.boundingClientRect?.top > 0; + const isAboveViewport = entry?.boundingClientRect?.top < window.innerHeight; useEffect(() => { if (cellData && isAboveViewport && !hasRequested) { diff --git a/src/admin/components/views/collections/List/RelationshipProvider/index.tsx b/src/admin/components/views/collections/List/RelationshipProvider/index.tsx index b3f8b32b51..be93b8a7ea 100644 --- a/src/admin/components/views/collections/List/RelationshipProvider/index.tsx +++ b/src/admin/components/views/collections/List/RelationshipProvider/index.tsx @@ -9,9 +9,9 @@ import useDebounce from '../../../../../hooks/useDebounce'; // set to false when no doc is returned // or set to the document returned export type Documents = { - [slug: string]: { - [id: string | number]: TypeWithID | null | false - } + [slug: string]: { + [id: string | number]: TypeWithID | null | false + } } type ListRelationshipContext = { @@ -24,7 +24,7 @@ type ListRelationshipContext = { const Context = createContext({} as ListRelationshipContext); -export const RelationshipProvider: React.FC<{children?: React.ReactNode}> = ({ children }) => { +export const RelationshipProvider: React.FC<{ children?: React.ReactNode }> = ({ children }) => { const [documents, dispatchDocuments] = useReducer(reducer, {}); const debouncedDocuments = useDebounce(documents, 100); const config = useConfig(); @@ -48,7 +48,7 @@ export const RelationshipProvider: React.FC<{children?: React.ReactNode}> = ({ c const params = { depth: 0, 'where[id][in]': idsToLoad, - pagination: false, + limit: 250, }; const query = querystring.stringify(params, { addQueryPrefix: true }); diff --git a/src/admin/hooks/useThrottledEffect.tsx b/src/admin/hooks/useThrottledEffect.tsx index 79d1125b7e..362112870a 100644 --- a/src/admin/hooks/useThrottledEffect.tsx +++ b/src/admin/hooks/useThrottledEffect.tsx @@ -4,10 +4,10 @@ import { useEffect, useRef } from 'react'; type useThrottledEffect = (callback: React.EffectCallback, delay: number, deps: React.DependencyList) => void; const useThrottledEffect: useThrottledEffect = (callback, delay, deps = []) => { - const lastRan = useRef(Date.now()); + const lastRan = useRef(null); - useEffect( - () => { + useEffect(() => { + if (lastRan) { const handler = setTimeout(() => { if (Date.now() - lastRan.current >= delay) { callback(); @@ -18,9 +18,12 @@ const useThrottledEffect: useThrottledEffect = (callback, delay, deps = []) => { return () => { clearTimeout(handler); }; - }, - [delay, ...deps], - ); + } + + callback(); + lastRan.current = Date.now(); + return () => null; + }, [delay, ...deps]); }; export default useThrottledEffect; diff --git a/src/admin/index.tsx b/src/admin/index.tsx index f0b0eba94f..cb39841554 100644 --- a/src/admin/index.tsx +++ b/src/admin/index.tsx @@ -35,6 +35,7 @@ const Index = () => ( diff --git a/src/admin/scss/vars.scss b/src/admin/scss/vars.scss index 3fd3813ad5..3e8a8f17a6 100644 --- a/src/admin/scss/vars.scss +++ b/src/admin/scss/vars.scss @@ -129,7 +129,7 @@ $focus-box-shadow: 0 0 0 $style-stroke-width-m var(--theme-success-500); &:before { background: $color; - opacity: .9; + opacity: .85; } &:after { @@ -181,4 +181,4 @@ $focus-box-shadow: 0 0 0 $style-stroke-width-m var(--theme-success-500); border-color: var(--theme-elevation-150); } } -} +} \ No newline at end of file diff --git a/src/auth/operations/local/forgotPassword.ts b/src/auth/operations/local/forgotPassword.ts index cff6b45907..81ea1fc46e 100644 --- a/src/auth/operations/local/forgotPassword.ts +++ b/src/auth/operations/local/forgotPassword.ts @@ -19,15 +19,12 @@ async function localForgotPassword(payload: Payload, options: Options): Promise< data, expiration, disableEmail, - req: incomingReq = {}, + req = {} as PayloadRequest, } = options; const collection = payload.collections[collectionSlug]; - const req = { - ...incomingReq, - payloadAPI: 'local', - } as PayloadRequest; + req.payloadAPI = 'local'; if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req); diff --git a/src/auth/operations/local/login.ts b/src/auth/operations/local/login.ts index 0587c09956..281fa3f17d 100644 --- a/src/auth/operations/local/login.ts +++ b/src/auth/operations/local/login.ts @@ -23,7 +23,7 @@ export type Options = { async function localLogin(payload: Payload, options: Options): Promise { const { collection: collectionSlug, - req: incomingReq = {}, + req = {} as PayloadRequest, res, depth, locale, @@ -35,13 +35,10 @@ async function localLogin(payload: Payload, options: const collection = payload.collections[collectionSlug]; - const req = { - ...incomingReq, - payloadAPI: 'local', - payload, - locale: undefined, - fallbackLocale: undefined, - } as PayloadRequest; + req.payloadAPI = 'local'; + req.payload = payload; + req.locale = undefined; + req.fallbackLocale = undefined; if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req); diff --git a/src/auth/operations/local/resetPassword.ts b/src/auth/operations/local/resetPassword.ts index e37f806247..1e04f07bf1 100644 --- a/src/auth/operations/local/resetPassword.ts +++ b/src/auth/operations/local/resetPassword.ts @@ -18,16 +18,13 @@ async function localResetPassword(payload: Payload, options: Options): Promise collection: collectionSlug, data, overrideAccess = true, - req: incomingReq = {}, + req = {} as PayloadRequest, } = options; const collection = payload.collections[collectionSlug]; - const req = { - ...incomingReq, - payload, - payloadAPI: 'local', - } as PayloadRequest; + req.payload = payload; + req.payloadAPI = 'local'; if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req); diff --git a/src/auth/types.ts b/src/auth/types.ts index f96357ca14..66fa0e3c88 100644 --- a/src/auth/types.ts +++ b/src/auth/types.ts @@ -62,13 +62,13 @@ export interface UserDocument extends PayloadMongooseDocument { email: string } -type GenerateVerifyEmailHTML = (args: { req: PayloadRequest, token: string, user: any}) => Promise | string -type GenerateVerifyEmailSubject = (args: { req: PayloadRequest, token: string, user: any}) => Promise | string +type GenerateVerifyEmailHTML = (args: { req: PayloadRequest, token: string, user: any }) => Promise | string +type GenerateVerifyEmailSubject = (args: { req: PayloadRequest, token: string, user: any }) => Promise | string -type GenerateForgotPasswordEmailHTML = (args?: { req?: PayloadRequest, token?: string, user?: unknown}) => Promise | string +type GenerateForgotPasswordEmailHTML = (args?: { req?: PayloadRequest, token?: string, user?: unknown }) => Promise | string type GenerateForgotPasswordEmailSubject = (args?: { req?: PayloadRequest, token?: string, user?: any }) => Promise | string -type AuthStrategy = (ctx: Payload) => Strategy | Strategy; +type AuthStrategy = ((ctx: Payload) => Strategy) | Strategy; export interface IncomingAuthType { tokenExpiration?: number; diff --git a/src/bin/generateTypes.ts b/src/bin/generateTypes.ts index b1a6fa3234..8016c16799 100644 --- a/src/bin/generateTypes.ts +++ b/src/bin/generateTypes.ts @@ -390,6 +390,7 @@ function configToJsonSchema(config: SanitizedConfig): JSONSchema4 { export function generateTypes(): void { const logger = Logger(); const config = loadConfig(); + const outputFile = process.env.PAYLOAD_TS_OUTPUT_PATH || config.typescript.outputFile; logger.info('Compiling TS types for Collections and Globals...'); @@ -402,8 +403,8 @@ export function generateTypes(): void { singleQuote: true, }, }).then((compiled) => { - fs.writeFileSync(config.typescript.outputFile, compiled); - logger.info(`Types written to ${config.typescript.outputFile}`); + fs.writeFileSync(outputFile, compiled); + logger.info(`Types written to ${outputFile}`); }); } diff --git a/src/collections/buildEndpoints.ts b/src/collections/buildEndpoints.ts index 2d03fd146a..e62c4da90c 100644 --- a/src/collections/buildEndpoints.ts +++ b/src/collections/buildEndpoints.ts @@ -16,7 +16,7 @@ import findVersionByID from './requestHandlers/findVersionByID'; import restoreVersion from './requestHandlers/restoreVersion'; import deleteHandler from './requestHandlers/delete'; import findByID from './requestHandlers/findByID'; -import update from './requestHandlers/update'; +import update, { deprecatedUpdate } from './requestHandlers/update'; import logoutHandler from '../auth/requestHandlers/logout'; const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => { @@ -122,6 +122,11 @@ const buildEndpoints = (collection: SanitizedCollectionConfig): Endpoint[] => { { path: '/:id', method: 'put', + handler: deprecatedUpdate, + }, + { + path: '/:id', + method: 'patch', handler: update, }, { diff --git a/src/collections/buildSchema.ts b/src/collections/buildSchema.ts index 20ae38cb37..1de4479087 100644 --- a/src/collections/buildSchema.ts +++ b/src/collections/buildSchema.ts @@ -10,6 +10,7 @@ const buildCollectionSchema = (collection: SanitizedCollectionConfig, config: Sa config, collection.fields, { + draftsEnabled: Boolean(typeof collection?.versions === 'object' && collection.versions.drafts), options: { timestamps: collection.timestamps !== false, ...schemaOptions }, }, ); diff --git a/src/collections/config/schema.ts b/src/collections/config/schema.ts index 5439a86293..dd5adcb3d3 100644 --- a/src/collections/config/schema.ts +++ b/src/collections/config/schema.ts @@ -1,5 +1,6 @@ import joi from 'joi'; import { componentSchema } from '../../utilities/componentSchema'; +import { endpointsSchema } from '../../config/schema'; const strategyBaseSchema = joi.object().keys({ refresh: joi.boolean(), @@ -61,14 +62,7 @@ const collectionSchema = joi.object().keys({ afterRefresh: joi.array().items(joi.func()), afterForgotPassword: joi.array().items(joi.func()), }), - endpoints: joi.array().items(joi.object({ - path: joi.string(), - method: joi.string().valid('get', 'head', 'post', 'put', 'patch', 'delete', 'connect', 'options'), - handler: joi.alternatives().try( - joi.array().items(joi.func()), - joi.func(), - ), - })), + endpoints: endpointsSchema, auth: joi.alternatives().try( joi.object({ tokenExpiration: joi.number(), diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index cd2017c21e..1e7396a492 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -165,8 +165,8 @@ export type CollectionAdminOptions = { */ components?: { views?: { - Edit?: React.ComponentType - List?: React.ComponentType + Edit?: React.ComponentType + List?: React.ComponentType } }; pagination?: { diff --git a/src/collections/dataloader.ts b/src/collections/dataloader.ts index bfae5748fd..b02e9bd7d4 100644 --- a/src/collections/dataloader.ts +++ b/src/collections/dataloader.ts @@ -1,6 +1,9 @@ import DataLoader, { BatchLoadFn } from 'dataloader'; import { PayloadRequest } from '../express/types'; import { TypeWithID } from '../globals/config/types'; +import { isValidID } from '../utilities/isValidID'; +import { getIDType } from '../utilities/getIDType'; +import { fieldAffectsData } from '../fields/config/types'; // Payload uses `dataloader` to solve the classic GraphQL N+1 problem. @@ -49,13 +52,18 @@ const batchAndLoadDocs = (req: PayloadRequest): BatchLoadFn const batchKey = JSON.stringify(batchKeyArray); - return { - ...batches, - [batchKey]: [ - ...batches[batchKey] || [], - id, - ], - }; + const idField = payload.collections?.[collection].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); + + if (isValidID(id, getIDType(idField))) { + return { + ...batches, + [batchKey]: [ + ...batches[batchKey] || [], + id, + ], + }; + } + return batches; }, {}); // Run find requests in parallel diff --git a/src/collections/graphql/init.ts b/src/collections/graphql/init.ts index b000926ccb..ddbc3b0a36 100644 --- a/src/collections/graphql/init.ts +++ b/src/collections/graphql/init.ts @@ -177,6 +177,9 @@ function initCollectionsGraphQL(payload: Payload): void { args: { data: { type: collection.graphQL.mutationInputType }, draft: { type: GraphQLBoolean }, + ...(payload.config.localization ? { + locale: { type: payload.types.localeInputType }, + } : {}), }, resolve: createResolver(collection), }; @@ -188,6 +191,9 @@ function initCollectionsGraphQL(payload: Payload): void { data: { type: collection.graphQL.updateMutationInputType }, draft: { type: GraphQLBoolean }, autosave: { type: GraphQLBoolean }, + ...(payload.config.localization ? { + locale: { type: payload.types.localeInputType }, + } : {}), }, resolve: updateResolver(collection), }; diff --git a/src/collections/init.ts b/src/collections/init.ts index feb0b89b2e..fb94bdde98 100644 --- a/src/collections/init.ts +++ b/src/collections/init.ts @@ -1,9 +1,8 @@ -import mongoose, { UpdateAggregationStage } from 'mongoose'; +import mongoose, { UpdateAggregationStage, UpdateQuery } from 'mongoose'; import paginate from 'mongoose-paginate-v2'; import express from 'express'; import passport from 'passport'; import passportLocalMongoose from 'passport-local-mongoose'; -import { UpdateQuery } from 'mongodb'; import { buildVersionCollectionFields } from '../versions/buildCollectionFields'; import buildQueryPlugin from '../mongoose/buildQuery'; import apiKeyStrategy from '../auth/strategies/apiKey'; diff --git a/src/collections/operations/local/create.ts b/src/collections/operations/local/create.ts index 6dd74fc10c..452a4cfca2 100644 --- a/src/collections/operations/local/create.ts +++ b/src/collections/operations/local/create.ts @@ -1,9 +1,9 @@ +import { UploadedFile } from 'express-fileupload'; import { Payload } from '../../..'; import { PayloadRequest } from '../../../express/types'; import { Document } from '../../../types'; import getFileByPath from '../../../uploads/getFileByPath'; import create from '../create'; -import { File } from '../../../uploads/types'; import { getDataLoader } from '../../dataloader'; @@ -18,7 +18,7 @@ export type Options = { disableVerificationEmail?: boolean showHiddenFields?: boolean filePath?: string - file?: File + file?: UploadedFile overwriteExistingFiles?: boolean req?: PayloadRequest draft?: boolean @@ -38,23 +38,21 @@ export default async function createLocal(payload: Payload, options: Op filePath, file, overwriteExistingFiles = false, - req: incomingReq, + req = {} as PayloadRequest, draft, } = options; const collection = payload.collections[collectionSlug]; - const req = { - ...incomingReq || {}, - user, - payloadAPI: 'local', - locale: locale || incomingReq?.locale || (payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null), - fallbackLocale: fallbackLocale || incomingReq?.fallbackLocale || null, - payload, - files: { - file: file ?? getFileByPath(filePath), - }, - } as PayloadRequest; + req.payloadAPI = 'local'; + req.locale = locale || req?.locale || (payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null); + req.fallbackLocale = fallbackLocale || req?.fallbackLocale || null; + req.payload = payload; + req.files = { + file: (file as UploadedFile) ?? (getFileByPath(filePath) as UploadedFile), + }; + + if (typeof user !== 'undefined') req.user = user; if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req); diff --git a/src/collections/operations/local/find.ts b/src/collections/operations/local/find.ts index 3955d6b7c7..1fa20492c2 100644 --- a/src/collections/operations/local/find.ts +++ b/src/collections/operations/local/find.ts @@ -43,19 +43,15 @@ export default async function findLocal(payload: Pay sort, draft = false, pagination = true, - req: incomingReq, + req = {} as PayloadRequest, } = options; const collection = payload.collections[collectionSlug]; - const req = { - user: undefined, - ...incomingReq || {}, - payloadAPI: 'local', - locale: locale || incomingReq?.locale || (payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null), - fallbackLocale: fallbackLocale || incomingReq?.fallbackLocale || null, - payload, - } as PayloadRequest; + req.payloadAPI = 'local'; + req.locale = locale || req?.locale || (payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null); + req.fallbackLocale = fallbackLocale || req?.fallbackLocale || null; + req.payload = payload; if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req); diff --git a/src/collections/operations/local/findByID.ts b/src/collections/operations/local/findByID.ts index 819dcbd10d..eb16079dfe 100644 --- a/src/collections/operations/local/findByID.ts +++ b/src/collections/operations/local/findByID.ts @@ -33,20 +33,16 @@ export default async function findByIDLocal(payload: overrideAccess = true, disableErrors = false, showHiddenFields, - req: incomingReq, + req = {} as PayloadRequest, draft = false, } = options; const collection = payload.collections[collectionSlug]; - const req = { - user: undefined, - ...incomingReq || {}, - payloadAPI: 'local', - locale: locale || incomingReq?.locale || (payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null), - fallbackLocale: fallbackLocale || incomingReq?.fallbackLocale || null, - payload, - } as PayloadRequest; + req.payloadAPI = 'local'; + req.locale = locale || req?.locale || (payload?.config?.localization ? payload?.config?.localization?.defaultLocale : null); + req.fallbackLocale = fallbackLocale || req?.fallbackLocale || null; + req.payload = payload; if (typeof user !== 'undefined') req.user = user; diff --git a/src/collections/operations/local/findVersionByID.ts b/src/collections/operations/local/findVersionByID.ts index 1eedd589a3..2d12eac6cb 100644 --- a/src/collections/operations/local/findVersionByID.ts +++ b/src/collections/operations/local/findVersionByID.ts @@ -28,18 +28,15 @@ export default async function findVersionByIDLocal overrideAccess = true, disableErrors = false, showHiddenFields, - req: incomingReq, + req = {} as PayloadRequest, } = options; const collection = payload.collections[collectionSlug]; - const req = { - ...incomingReq || {}, - payloadAPI: 'local', - locale: locale || incomingReq?.locale || this?.config?.localization?.defaultLocale, - fallbackLocale: fallbackLocale || incomingReq?.fallbackLocale || null, - payload, - } as PayloadRequest; + req.payloadAPI = 'local'; + req.locale = locale || req?.locale || this?.config?.localization?.defaultLocale; + req.fallbackLocale = fallbackLocale || req?.fallbackLocale || null; + req.payload = payload; if (!req.payloadDataLoader) req.payloadDataLoader = getDataLoader(req); diff --git a/src/collections/requestHandlers/update.ts b/src/collections/requestHandlers/update.ts index 8b2f7c2e84..22028290e9 100644 --- a/src/collections/requestHandlers/update.ts +++ b/src/collections/requestHandlers/update.ts @@ -9,6 +9,12 @@ export type UpdateResult = { doc: Document }; +export async function deprecatedUpdate(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { + req.payload.logger.warn('The PUT method is deprecated and will no longer be supported in a future release. Please use the PATCH method for update requests.'); + + return updateHandler(req, res, next); +} + export default async function updateHandler(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { try { const draft = req.query.draft === 'true'; diff --git a/src/config/defaults.ts b/src/config/defaults.ts index ff232e5ace..725312f825 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -7,6 +7,7 @@ export const defaults: Config = { maxDepth: 10, collections: [], globals: [], + endpoints: [], cookiePrefix: 'payload', csrf: [], cors: [], diff --git a/src/config/schema.ts b/src/config/schema.ts index 76d3a6a5c9..5bd744b4d5 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -5,6 +5,15 @@ const component = joi.alternatives().try( joi.func(), ); +export const endpointsSchema = joi.array().items(joi.object({ + path: joi.string(), + method: joi.string().valid('get', 'head', 'post', 'put', 'patch', 'delete', 'connect', 'options'), + handler: joi.alternatives().try( + joi.array().items(joi.func()), + joi.func(), + ), +})); + export default joi.object({ serverURL: joi.string() .uri() @@ -33,6 +42,7 @@ export default joi.object({ outputFile: joi.string(), }), collections: joi.array(), + endpoints: endpointsSchema, globals: joi.array(), admin: joi.object({ user: joi.string(), diff --git a/src/config/types.ts b/src/config/types.ts index 7844ef013a..1a696c4a31 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,4 +1,4 @@ -import { Express, Handler } from 'express'; +import { Express, NextFunction, Response } from 'express'; import { DeepRequired } from 'ts-essentials'; import { Transporter } from 'nodemailer'; import { Options } from 'express-fileupload'; @@ -79,10 +79,16 @@ export type AccessResult = boolean | Where; */ export type Access = (args?: any) => AccessResult | Promise; +export interface PayloadHandler {( + req: PayloadRequest, + res: Response, + next: NextFunction, + ): void } + export type Endpoint = { path: string method: 'get' | 'head' | 'post' | 'put' | 'patch' | 'delete' | 'connect' | 'options' | string - handler: Handler | Handler[] + handler: PayloadHandler | PayloadHandler[] } export type AdminView = React.ComponentType<{ user: User, canAccessAdmin: boolean }> @@ -116,20 +122,20 @@ export type Config = { components?: { routes?: AdminRoute[] providers?: React.ComponentType<{ children: React.ReactNode }>[] - beforeDashboard?: React.ComponentType[] - afterDashboard?: React.ComponentType[] - beforeLogin?: React.ComponentType[] - afterLogin?: React.ComponentType[] - beforeNavLinks?: React.ComponentType[] - afterNavLinks?: React.ComponentType[] - Nav?: React.ComponentType + beforeDashboard?: React.ComponentType[] + afterDashboard?: React.ComponentType[] + beforeLogin?: React.ComponentType[] + afterLogin?: React.ComponentType[] + beforeNavLinks?: React.ComponentType[] + afterNavLinks?: React.ComponentType[] + Nav?: React.ComponentType graphics?: { - Icon?: React.ComponentType - Logo?: React.ComponentType + Icon?: React.ComponentType + Logo?: React.ComponentType } views?: { - Account?: React.ComponentType - Dashboard?: React.ComponentType + Account?: React.ComponentType + Dashboard?: React.ComponentType } } pagination?: { @@ -139,6 +145,7 @@ export type Config = { webpack?: (config: Configuration) => Configuration; }; collections?: CollectionConfig[]; + endpoints?: Endpoint[]; globals?: GlobalConfig[]; serverURL?: string; cookiePrefix?: string; @@ -202,4 +209,4 @@ export type SanitizedConfig = Omit, 'collections' | 'global paths: { [key: string]: string }; } -export type EntityDescription = string | (() => string) | React.ComponentType +export type EntityDescription = string | (() => string) | React.ComponentType diff --git a/src/express/middleware/corsHeaders.ts b/src/express/middleware/corsHeaders.ts index 8337e5a225..5e68eadd01 100644 --- a/src/express/middleware/corsHeaders.ts +++ b/src/express/middleware/corsHeaders.ts @@ -4,7 +4,7 @@ import { SanitizedConfig } from '../../config/types'; export default (config: SanitizedConfig) => ( (req: Request, res: Response, next: NextFunction) => { if (config.cors) { - res.header('Access-Control-Allow-Methods', 'PUT, POST, GET, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Methods', 'PUT, PATCH, POST, GET, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Content-Encoding'); if (config.cors === '*') { diff --git a/src/fields/config/sanitize.ts b/src/fields/config/sanitize.ts index 277e173bbf..6cc387c45d 100644 --- a/src/fields/config/sanitize.ts +++ b/src/fields/config/sanitize.ts @@ -37,11 +37,11 @@ const sanitizeFields = (fields: Field[], validRelationships: string[]): Field[] }); } - if (field.type === 'blocks') { + if (field.type === 'blocks' && field.blocks) { field.blocks = field.blocks.map((block) => ({ ...block, fields: block.fields.concat(baseBlockFields) })); } - if (field.type === 'array') { + if (field.type === 'array' && field.fields) { field.fields.push(baseIDField); } diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index aa0f4d3fb7..faec9118b1 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -131,13 +131,15 @@ export const code = baseField.keys({ export const select = baseField.keys({ type: joi.string().valid('select').required(), name: joi.string().required(), - options: joi.array().items(joi.alternatives().try( - joi.string(), - joi.object({ - value: joi.string().required().allow(''), - label: joi.string().required(), - }), - )).required(), + options: joi.array().min(1).items( + joi.alternatives().try( + joi.string(), + joi.object({ + value: joi.string().required().allow(''), + label: joi.string().required(), + }), + ), + ).required(), hasMany: joi.boolean().default(false), defaultValue: joi.alternatives().try( joi.string().allow(''), @@ -146,19 +148,22 @@ export const select = baseField.keys({ ), admin: baseAdminFields.keys({ isClearable: joi.boolean().default(false), + isSortable: joi.boolean().default(false), }), }); export const radio = baseField.keys({ type: joi.string().valid('radio').required(), name: joi.string().required(), - options: joi.array().items(joi.alternatives().try( - joi.string(), - joi.object({ - value: joi.string().required().allow(''), - label: joi.string().required(), - }), - )).required(), + options: joi.array().min(1).items( + joi.alternatives().try( + joi.string(), + joi.object({ + value: joi.string().required().allow(''), + label: joi.string().required(), + }), + ), + ).required(), defaultValue: joi.alternatives().try( joi.string().allow(''), joi.func(), @@ -221,7 +226,7 @@ export const array = baseField.keys({ name: joi.string().required(), minRows: joi.number(), maxRows: joi.number(), - fields: joi.array().items(joi.link('#field')), + fields: joi.array().items(joi.link('#field')).required(), labels: joi.object({ singular: joi.string(), plural: joi.string(), @@ -277,6 +282,9 @@ export const relationship = baseField.keys({ defaultValue: joi.alternatives().try( joi.func(), ), + admin: baseAdminFields.keys({ + isSortable: joi.boolean().default(false), + }), }); export const blocks = baseField.keys({ @@ -299,7 +307,7 @@ export const blocks = baseField.keys({ }), fields: joi.array().items(joi.link('#field')), }), - ), + ).required(), defaultValue: joi.alternatives().try( joi.array().items(joi.object()), joi.func(), @@ -343,6 +351,9 @@ export const richText = baseField.keys({ fields: joi.array().items(joi.link('#field')), })), }), + link: joi.object({ + fields: joi.array().items(joi.link('#field')), + }), }), }); diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 70b726712d..3181e35d09 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -51,9 +51,9 @@ type Admin = { condition?: Condition; description?: Description; components?: { - Filter?: React.ComponentType; - Cell?: React.ComponentType; - Field?: React.ComponentType; + Filter?: React.ComponentType; + Cell?: React.ComponentType; + Field?: React.ComponentType; } hidden?: boolean } @@ -245,6 +245,7 @@ export type SelectField = FieldBase & { hasMany?: boolean admin?: Admin & { isClearable?: boolean; + isSortable?: boolean; } } @@ -254,6 +255,9 @@ export type RelationshipField = FieldBase & { hasMany?: boolean; maxDepth?: number; filterOptions?: FilterOptions; + admin?: Admin & { + isSortable?: boolean; + } } export type ValueWithRelation = { @@ -274,15 +278,15 @@ type RichTextPlugin = (editor: Editor) => Editor; export type RichTextCustomElement = { name: string - Button: React.ComponentType - Element: React.ComponentType + Button: React.ComponentType + Element: React.ComponentType plugins?: RichTextPlugin[] } export type RichTextCustomLeaf = { name: string - Button: React.ComponentType - Leaf: React.ComponentType + Button: React.ComponentType + Leaf: React.ComponentType plugins?: RichTextPlugin[] } @@ -303,6 +307,9 @@ export type RichTextField = FieldBase & { } } } + link?: { + fields?: Field[]; + } } } @@ -311,7 +318,7 @@ export type ArrayField = FieldBase & { minRows?: number; maxRows?: number; labels?: Labels; - fields?: Field[]; + fields: Field[]; } export type RadioField = FieldBase & { @@ -334,7 +341,7 @@ export type BlockField = FieldBase & { type: 'blocks'; minRows?: number; maxRows?: number; - blocks?: Block[]; + blocks: Block[]; defaultValue?: unknown labels?: Labels } @@ -384,7 +391,8 @@ export type FieldAffectingData = | CodeField | PointField -export type NonPresentationalField = TextField +export type NonPresentationalField = + TextField | NumberField | EmailField | TextareaField diff --git a/src/fields/getDefaultValue.ts b/src/fields/getDefaultValue.ts index fcfc992ced..db69b4e66c 100644 --- a/src/fields/getDefaultValue.ts +++ b/src/fields/getDefaultValue.ts @@ -15,9 +15,8 @@ const getValueWithDefault = async ({ value, defaultValue, locale, user }: Args): if (defaultValue && typeof defaultValue === 'function') { return defaultValue({ locale, user }); } - return defaultValue; - return undefined; + return defaultValue; }; export default getValueWithDefault; diff --git a/src/fields/hooks/afterRead/promise.ts b/src/fields/hooks/afterRead/promise.ts index 908c05ec1b..fbba713c69 100644 --- a/src/fields/hooks/afterRead/promise.ts +++ b/src/fields/hooks/afterRead/promise.ts @@ -2,7 +2,7 @@ import { Field, fieldAffectsData, tabHasName } from '../../config/types'; import { PayloadRequest } from '../../../express/types'; import { traverseFields } from './traverseFields'; -import richTextRelationshipPromise from '../../richText/relationshipPromise'; +import richTextRelationshipPromise from '../../richText/richTextRelationshipPromise'; import relationshipPopulationPromise from './relationshipPopulationPromise'; type Args = { @@ -47,11 +47,11 @@ export const promise = async ({ } const hasLocalizedValue = flattenLocales - && fieldAffectsData(field) - && (typeof siblingDoc[field.name] === 'object' && siblingDoc[field.name] !== null) - && field.name - && field.localized - && req.locale !== 'all'; + && fieldAffectsData(field) + && (typeof siblingDoc[field.name] === 'object' && siblingDoc[field.name] !== null) + && field.name + && field.localized + && req.locale !== 'all'; if (hasLocalizedValue) { let localizedValue = siblingDoc[field.name][req.locale]; @@ -119,8 +119,8 @@ export const promise = async ({ await priorHook; const shouldRunHookOnAllLocales = field.localized - && (req.locale === 'all' || !flattenLocales) - && typeof siblingDoc[field.name] === 'object'; + && (req.locale === 'all' || !flattenLocales) + && typeof siblingDoc[field.name] === 'object'; if (shouldRunHookOnAllLocales) { const hookPromises = Object.entries(siblingDoc[field.name]).map(([locale, value]) => (async () => { diff --git a/src/fields/richText/recurseNestedFields.ts b/src/fields/richText/recurseNestedFields.ts index ce884fc6f9..eaac69edfc 100644 --- a/src/fields/richText/recurseNestedFields.ts +++ b/src/fields/richText/recurseNestedFields.ts @@ -2,7 +2,7 @@ import { Field, fieldHasSubFields, fieldIsArrayType, fieldAffectsData } from '../config/types'; import { PayloadRequest } from '../../express/types'; import { populate } from './populate'; -import { recurseRichText } from './relationshipPromise'; +import { recurseRichText } from './richTextRelationshipPromise'; type NestedRichTextFieldsArgs = { promises: Promise[] diff --git a/src/fields/richText/relationshipPromise.ts b/src/fields/richText/relationshipPromise.ts deleted file mode 100644 index f6a4bfbc0d..0000000000 --- a/src/fields/richText/relationshipPromise.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { RichTextField } from '../config/types'; -import { PayloadRequest } from '../../express/types'; -import { recurseNestedFields } from './recurseNestedFields'; -import { populate } from './populate'; - -type Args = { - currentDepth?: number - depth: number - field: RichTextField - overrideAccess?: boolean - req: PayloadRequest - siblingDoc: Record - showHiddenFields: boolean -} - -type RecurseRichTextArgs = { - children: unknown[] - overrideAccess: boolean - depth: number - currentDepth: number - field: RichTextField - req: PayloadRequest - promises: Promise[] - showHiddenFields: boolean -} - -export const recurseRichText = ({ - req, - children, - overrideAccess = false, - depth, - currentDepth = 0, - field, - promises, - showHiddenFields, -}: RecurseRichTextArgs): void => { - if (Array.isArray(children)) { - (children as any[]).forEach((element) => { - const collection = req.payload.collections[element?.relationTo]; - - if ((element.type === 'relationship' || element.type === 'upload') - && element?.value?.id - && collection - && (depth && currentDepth <= depth)) { - if (element.type === 'upload' && Array.isArray(field.admin?.upload?.collections?.[element?.relationTo]?.fields)) { - recurseNestedFields({ - promises, - data: element.fields || {}, - fields: field.admin.upload.collections[element.relationTo].fields, - req, - overrideAccess, - depth, - currentDepth, - showHiddenFields, - }); - } - promises.push(populate({ - req, - id: element.value.id, - data: element, - key: 'value', - overrideAccess, - depth, - currentDepth, - field, - collection, - showHiddenFields, - })); - } - - if (element?.children) { - recurseRichText({ - children: element.children, - currentDepth, - depth, - field, - overrideAccess, - promises, - req, - showHiddenFields, - }); - } - }); - } -}; - -const richTextRelationshipPromise = async ({ - currentDepth, - depth, - field, - overrideAccess, - req, - siblingDoc, - showHiddenFields, -}: Args): Promise => { - const promises = []; - - recurseRichText({ - children: siblingDoc[field.name] as unknown[], - currentDepth, - depth, - field, - overrideAccess, - promises, - req, - showHiddenFields, - }); - - await Promise.all(promises); -}; - -export default richTextRelationshipPromise; diff --git a/src/fields/richText/richTextRelationshipPromise.ts b/src/fields/richText/richTextRelationshipPromise.ts new file mode 100644 index 0000000000..80a9cbddc6 --- /dev/null +++ b/src/fields/richText/richTextRelationshipPromise.ts @@ -0,0 +1,149 @@ +import { RichTextField } from '../config/types'; +import { PayloadRequest } from '../../express/types'; +import { recurseNestedFields } from './recurseNestedFields'; +import { populate } from './populate'; + +type Args = { + currentDepth?: number + depth: number + field: RichTextField + overrideAccess?: boolean + req: PayloadRequest + siblingDoc: Record + showHiddenFields: boolean +} + +type RecurseRichTextArgs = { + children: unknown[] + overrideAccess: boolean + depth: number + currentDepth: number + field: RichTextField + req: PayloadRequest + promises: Promise[] + showHiddenFields: boolean +} + +export const recurseRichText = ({ + req, + children, + overrideAccess = false, + depth, + currentDepth = 0, + field, + promises, + showHiddenFields, +}: RecurseRichTextArgs): void => { + if (Array.isArray(children)) { + (children as any[]).forEach((element) => { + if ((depth && currentDepth <= depth)) { + if ((element.type === 'relationship' || element.type === 'upload') + && element?.value?.id) { + const collection = req.payload.collections[element?.relationTo]; + + if (collection) { + promises.push(populate({ + req, + id: element.value.id, + data: element, + key: 'value', + overrideAccess, + depth, + currentDepth, + field, + collection, + showHiddenFields, + })); + } + + if (element.type === 'upload' && Array.isArray(field.admin?.upload?.collections?.[element?.relationTo]?.fields)) { + recurseNestedFields({ + promises, + data: element.fields || {}, + fields: field.admin.upload.collections[element.relationTo].fields, + req, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + } + } + + if (element.type === 'link') { + if (element?.doc?.value && element?.doc?.relationTo) { + const collection = req.payload.collections[element?.doc?.relationTo]; + + if (collection) { + promises.push(populate({ + req, + id: element.doc.value, + data: element.doc, + key: 'value', + overrideAccess, + depth, + currentDepth, + field, + collection, + showHiddenFields, + })); + } + } + + if (Array.isArray(field.admin?.link?.fields)) { + recurseNestedFields({ + promises, + data: element.fields || {}, + fields: field.admin?.link?.fields, + req, + overrideAccess, + depth, + currentDepth, + showHiddenFields, + }); + } + } + } + + if (element?.children) { + recurseRichText({ + children: element.children, + currentDepth, + depth, + field, + overrideAccess, + promises, + req, + showHiddenFields, + }); + } + }); + } +}; + +const richTextRelationshipPromise = async ({ + currentDepth, + depth, + field, + overrideAccess, + req, + siblingDoc, + showHiddenFields, +}: Args): Promise => { + const promises = []; + + recurseRichText({ + children: siblingDoc[field.name] as unknown[], + currentDepth, + depth, + field, + overrideAccess, + promises, + req, + showHiddenFields, + }); + + await Promise.all(promises); +}; + +export default richTextRelationshipPromise; diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 1220062bc6..87da3352e6 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -17,9 +17,12 @@ import { TextField, UploadField, Validate, + fieldAffectsData, } from './config/types'; import { TypeWithID } from '../collections/config/types'; import canUseDOM from '../utilities/canUseDOM'; +import { isValidID } from '../utilities/isValidID'; +import { getIDType } from '../utilities/getIDType'; const defaultMessage = 'This field is required.'; @@ -232,6 +235,15 @@ export const upload: Validate = (value: string, o return defaultMessage; } + if (!canUseDOM && typeof value !== 'undefined' && value !== null) { + const idField = options.payload.collections[options.relationTo].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); + const type = getIDType(idField); + + if (!isValidID(value, type)) { + return 'This field is not a valid upload ID'; + } + } + return validateFilterOptions(value, options); }; @@ -240,6 +252,45 @@ export const relationship: Validate = async return defaultMessage; } + if (!canUseDOM && typeof value !== 'undefined' && value !== null) { + const values = Array.isArray(value) ? value : [value]; + + const invalidRelationships = values.filter((val) => { + let collection: string; + let requestedID: string | number; + + if (typeof options.relationTo === 'string') { + collection = options.relationTo; + + // custom id + if (typeof val === 'string' || typeof val === 'number') { + requestedID = val; + } + } + + if (Array.isArray(options.relationTo) && typeof val === 'object' && val?.relationTo) { + collection = val.relationTo; + requestedID = val.value; + } + + const idField = options.payload.collections[collection].config.fields.find((field) => fieldAffectsData(field) && field.name === 'id'); + let type; + if (idField) { + type = idField.type === 'number' ? 'number' : 'text'; + } else { + type = 'ObjectID'; + } + + return !isValidID(requestedID, type); + }); + + if (invalidRelationships.length > 0) { + return `This field has the following invalid selections: ${invalidRelationships.map((err, invalid) => { + return `${err} ${JSON.stringify(invalid)}`; + }).join(', ')}` as string; + } + } + return validateFilterOptions(value, options); }; diff --git a/src/globals/config/schema.ts b/src/globals/config/schema.ts index 77f3a75394..72987857e0 100644 --- a/src/globals/config/schema.ts +++ b/src/globals/config/schema.ts @@ -1,5 +1,6 @@ import joi from 'joi'; import { componentSchema } from '../../utilities/componentSchema'; +import { endpointsSchema } from '../../config/schema'; const globalSchema = joi.object().keys({ slug: joi.string().required(), @@ -18,14 +19,7 @@ const globalSchema = joi.object().keys({ beforeRead: joi.array().items(joi.func()), afterRead: joi.array().items(joi.func()), }), - endpoints: joi.array().items(joi.object({ - path: joi.string(), - method: joi.string().valid('get', 'head', 'post', 'put', 'patch', 'delete', 'connect', 'options'), - handler: joi.alternatives().try( - joi.array().items(joi.func()), - joi.func(), - ), - })), + endpoints: endpointsSchema, access: joi.object({ read: joi.func(), readVersions: joi.func(), diff --git a/src/globals/config/types.ts b/src/globals/config/types.ts index b0fcaa47d9..50aba5405b 100644 --- a/src/globals/config/types.ts +++ b/src/globals/config/types.ts @@ -68,7 +68,7 @@ export type GlobalConfig = { hideAPIURL?: boolean; components?: { views?: { - Edit?: React.ComponentType + Edit?: React.ComponentType } } } diff --git a/src/globals/graphql/init.ts b/src/globals/graphql/init.ts index a997fa65e4..89da370e63 100644 --- a/src/globals/graphql/init.ts +++ b/src/globals/graphql/init.ts @@ -62,6 +62,9 @@ function initGlobalsGraphQL(payload: Payload): void { args: { data: { type: global.graphQL.mutationInputType }, draft: { type: GraphQLBoolean }, + ...(payload.config.localization ? { + locale: { type: payload.types.localeInputType }, + } : {}), }, resolve: updateResolver(global), }; diff --git a/src/graphql/schema/buildFallbackLocaleInputType.ts b/src/graphql/schema/buildFallbackLocaleInputType.ts index 4dbfeeee9a..71e58a0005 100644 --- a/src/graphql/schema/buildFallbackLocaleInputType.ts +++ b/src/graphql/schema/buildFallbackLocaleInputType.ts @@ -1,11 +1,12 @@ import { GraphQLEnumType } from 'graphql'; import { LocalizationConfig } from '../../config/types'; +import formatName from '../utilities/formatName'; const buildFallbackLocaleInputType = (localization: LocalizationConfig): GraphQLEnumType => new GraphQLEnumType({ name: 'FallbackLocaleInputType', values: [...localization.locales, 'none'].reduce((values, locale) => ({ ...values, - [locale]: { + [formatName(locale)]: { value: locale, }, }), {}), diff --git a/src/graphql/schema/buildLocaleInputType.ts b/src/graphql/schema/buildLocaleInputType.ts index b115fdc917..8ed0843535 100644 --- a/src/graphql/schema/buildLocaleInputType.ts +++ b/src/graphql/schema/buildLocaleInputType.ts @@ -1,14 +1,17 @@ -import { GraphQLEnumType } from 'graphql'; +import { GraphQLEnumType, GraphQLScalarType } from 'graphql'; import { LocalizationConfig } from '../../config/types'; +import formatName from '../utilities/formatName'; -const buildLocaleInputType = (localization: LocalizationConfig): GraphQLEnumType => new GraphQLEnumType({ - name: 'LocaleInputType', - values: localization.locales.reduce((values, locale) => ({ - ...values, - [locale]: { - value: locale, - }, - }), {}), -}); +const buildLocaleInputType = (localization: LocalizationConfig): GraphQLEnumType | GraphQLScalarType => { + return new GraphQLEnumType({ + name: 'LocaleInputType', + values: localization.locales.reduce((values, locale) => ({ + ...values, + [formatName(locale)]: { + value: locale, + }, + }), {}), + }); +}; export default buildLocaleInputType; diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index b104ad0a2b..39ff724773 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -9,6 +9,7 @@ import { GraphQLFloat, GraphQLInt, GraphQLList, + GraphQLNonNull, GraphQLObjectType, GraphQLString, GraphQLType, @@ -42,11 +43,12 @@ import formatName from '../utilities/formatName'; import combineParentName from '../utilities/combineParentName'; import withNullableType from './withNullableType'; import { toWords } from '../../utilities/formatLabels'; -import createRichTextRelationshipPromise from '../../fields/richText/relationshipPromise'; +import createRichTextRelationshipPromise from '../../fields/richText/richTextRelationshipPromise'; import formatOptions from '../utilities/formatOptions'; import { Payload } from '../..'; import buildWhereInputType from './buildWhereInputType'; import buildBlockType from './buildBlockType'; +import isFieldNullable from './isFieldNullable'; type LocaleInputType = { locale: { @@ -108,7 +110,7 @@ function buildObjectType({ }), point: (objectTypeConfig: ObjectTypeConfig, field: PointField) => ({ ...objectTypeConfig, - [field.name]: { type: withNullableType(field, new GraphQLList(GraphQLFloat), forceNullable) }, + [field.name]: { type: withNullableType(field, new GraphQLList(new GraphQLNonNull(GraphQLFloat)), forceNullable) }, }), richText: (objectTypeConfig: ObjectTypeConfig, field: RichTextField) => ({ ...objectTypeConfig, @@ -229,7 +231,7 @@ function buildObjectType({ values: formatOptions(field), }); - type = field.hasMany ? new GraphQLList(type) : type; + type = field.hasMany ? new GraphQLList(new GraphQLNonNull(type)) : type; type = withNullableType(field, type, forceNullable); return { @@ -307,7 +309,7 @@ function buildObjectType({ const relationship = { args: relationshipArgs, - type: hasManyValues ? new GraphQLList(type) : type, + type: hasManyValues ? new GraphQLList(new GraphQLNonNull(type)) : type, extensions: { complexity: 10 }, async resolve(parent, args, context) { const value = parent[field.name]; @@ -412,18 +414,20 @@ function buildObjectType({ }, array: (objectTypeConfig: ObjectTypeConfig, field: ArrayField) => { const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label); + const type = buildObjectType({ payload, name: fullName, fields: field.fields, parentName: fullName, - forceNullable, + forceNullable: isFieldNullable(field, forceNullable), }); - const arrayType = new GraphQLList(withNullableType(field, type, forceNullable)); + + const arrayType = new GraphQLList(new GraphQLNonNull(type)); return { ...objectTypeConfig, - [field.name]: { type: arrayType }, + [field.name]: { type: withNullableType(field, arrayType) }, }; }, group: (objectTypeConfig: ObjectTypeConfig, field: GroupField) => { @@ -433,7 +437,7 @@ function buildObjectType({ name: fullName, parentName: fullName, fields: field.fields, - forceNullable, + forceNullable: isFieldNullable(field, forceNullable), }); return { @@ -446,22 +450,22 @@ function buildObjectType({ buildBlockType({ payload, block, - forceNullable, + forceNullable: isFieldNullable(field, forceNullable), }); return payload.types.blockTypes[block.slug]; }); const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label); - const type = new GraphQLList(new GraphQLUnionType({ + const type = new GraphQLList(new GraphQLNonNull(new GraphQLUnionType({ name: fullName, types: blockTypes, resolveType: (data) => payload.types.blockTypes[data.blockType].name, - })); + }))); return { ...objectTypeConfig, - [field.name]: { type }, + [field.name]: { type: withNullableType(field, type) }, }; }, row: (objectTypeConfig: ObjectTypeConfig, field: RowField) => field.fields.reduce((objectTypeConfigWithRowFields, subField) => { diff --git a/src/graphql/schema/isFieldNullable.ts b/src/graphql/schema/isFieldNullable.ts new file mode 100644 index 0000000000..c9915490d0 --- /dev/null +++ b/src/graphql/schema/isFieldNullable.ts @@ -0,0 +1,9 @@ +import { FieldAffectingData, fieldAffectsData } from '../../fields/config/types'; + +const isFieldNullable = (field: FieldAffectingData, force: boolean): boolean => { + const hasReadAccessControl = field.access && field.access.read; + const condition = field.admin && field.admin.condition; + return !(force && fieldAffectsData(field) && field.required && !field.localized && !condition && !hasReadAccessControl); +}; + +export default isFieldNullable; diff --git a/src/graphql/utilities/formatName.spec.ts b/src/graphql/utilities/formatName.spec.ts new file mode 100644 index 0000000000..528ff36a14 --- /dev/null +++ b/src/graphql/utilities/formatName.spec.ts @@ -0,0 +1,18 @@ +/* eslint-disable indent */ +/* eslint-disable jest/prefer-strict-equal */ +import formatName from './formatName'; + +describe('formatName', () => { + it.each` + char | expected + ${'á'} | ${'a'} + ${'è'} | ${'e'} + ${'í'} | ${'i'} + ${'ó'} | ${'o'} + ${'ú'} | ${'u'} + ${'ñ'} | ${'n'} + ${'ü'} | ${'u'} + `('should convert accented character: $char', ({ char, expected }) => { + expect(formatName(char)).toEqual(expected); + }); +}); diff --git a/src/graphql/utilities/formatName.ts b/src/graphql/utilities/formatName.ts index 979b9bf9cc..d1cde537bc 100644 --- a/src/graphql/utilities/formatName.ts +++ b/src/graphql/utilities/formatName.ts @@ -10,6 +10,10 @@ const formatName = (string: string): string => { } const formatted = sanitizedString + // Convert accented characters + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\./g, '_') .replace(/-|\//g, '_') .replace(/\+/g, '_') diff --git a/src/init.ts b/src/init.ts index 4cd883c59d..1f5d7daeb8 100644 --- a/src/init.ts +++ b/src/init.ts @@ -1,6 +1,7 @@ /* eslint-disable no-param-reassign */ import express, { NextFunction, Response } from 'express'; import crypto from 'crypto'; +import mongoose from 'mongoose'; import { InitOptions, @@ -30,6 +31,7 @@ import { Payload } from '.'; import loadConfig from './config/load'; import Logger from './utilities/logger'; import { getDataLoader } from './collections/dataloader'; +import mountEndpoints from './express/mountEndpoints'; export const init = (payload: Payload, options: InitOptions): void => { payload.logger.info('Starting Payload...'); @@ -104,6 +106,8 @@ export const init = (payload: Payload, options: InitOptions): void => { initGraphQLPlayground(payload); } + mountEndpoints(payload.router, payload.config.endpoints); + // Bind router to API payload.express.use(payload.config.routes.api, payload.router); @@ -124,6 +128,7 @@ export const initAsync = async (payload: Payload, options: InitOptions): Promise payload.mongoURL = options.mongoURL; if (payload.mongoURL) { + mongoose.set('strictQuery', false); payload.mongoMemoryServer = await connectMongoose(payload.mongoURL, options.mongoOptions, payload.logger); } @@ -138,6 +143,7 @@ export const initSync = (payload: Payload, options: InitOptions): void => { payload.mongoURL = options.mongoURL; if (payload.mongoURL) { + mongoose.set('strictQuery', false); connectMongoose(payload.mongoURL, options.mongoOptions, payload.logger); } diff --git a/src/mongoose/buildQuery.ts b/src/mongoose/buildQuery.ts index 3f8d0f766e..b1e90bed44 100644 --- a/src/mongoose/buildQuery.ts +++ b/src/mongoose/buildQuery.ts @@ -142,7 +142,7 @@ class ParamParser { }, ]; - pathSegments.forEach((segment, i) => { + pathSegments.every((segment, i) => { const lastIncompletePath = paths.find(({ complete }) => !complete); const { path } = lastIncompletePath; @@ -152,7 +152,7 @@ class ParamParser { if (currentSchemaPathType === 'nested') { lastIncompletePath.path = currentPath; - return; + return true; } const upcomingSegment = pathSegments[i + 1]; @@ -161,25 +161,25 @@ class ParamParser { const currentSchemaTypeOptions = getSchemaTypeOptions(currentSchemaType); if (currentSchemaTypeOptions.localized) { + const upcomingLocalizedPath = `${currentPath}.${upcomingSegment}`; + const upcomingSchemaTypeWithLocale = schema.path(upcomingLocalizedPath); + + if (upcomingSchemaTypeWithLocale) { + lastIncompletePath.path = currentPath; + return true; + } + const localePath = `${currentPath}.${this.locale}`; const localizedSchemaType = schema.path(localePath); if (localizedSchemaType || operator === 'near') { lastIncompletePath.path = localePath; - return; - } - - const upcomingPathWithLocale = `${currentPath}.${this.locale}.${upcomingSegment}`; - const upcomingSchemaTypeWithLocale = schema.path(upcomingPathWithLocale); - - if (upcomingSchemaTypeWithLocale) { - lastIncompletePath.path = upcomingPathWithLocale; - return; + return true; } } lastIncompletePath.path = currentPath; - return; + return true; } const priorSchemaType = schema.path(path); @@ -197,17 +197,16 @@ class ParamParser { ...paths, ...this.getLocalizedPaths(RefModel, remainingPath, operator), ]; - return; - } - if (priorSchemaType.instance === 'Mixed') { - lastIncompletePath.path = currentPath; + return false; } } - if (operator === 'near') { + if (operator === 'near' || currentSchemaPathType === 'adhocOrUndefined') { lastIncompletePath.path = currentPath; } + + return true; }); return paths; diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index d9cc9f7ca6..5fd6b92668 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -2,7 +2,7 @@ /* eslint-disable class-methods-use-this */ /* eslint-disable @typescript-eslint/no-use-before-define */ /* eslint-disable no-use-before-define */ -import { IndexDefinition, IndexOptions, Schema, SchemaOptions } from 'mongoose'; +import { IndexOptions, Schema, SchemaOptions, SchemaTypeOptions } from 'mongoose'; import { SanitizedConfig } from '../config/types'; import { ArrayField, @@ -32,28 +32,30 @@ import { TextField, UnnamedTab, UploadField, } from '../fields/config/types'; -import sortableFieldTypes from '../fields/sortableFieldTypes'; export type BuildSchemaOptions = { options?: SchemaOptions allowIDField?: boolean disableUnique?: boolean + draftsEnabled?: boolean global?: boolean } type FieldSchemaGenerator = (field: Field, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => void; -type Index = { - index: IndexDefinition - options?: IndexOptions -} +const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: BuildSchemaOptions) => { + const schema: SchemaTypeOptions = { + unique: (!buildSchemaOptions.disableUnique && field.unique) || false, + required: false, + index: field.index || (!buildSchemaOptions.disableUnique && field.unique) || false, + }; -const formatBaseSchema = (field: NonPresentationalField, buildSchemaOptions: BuildSchemaOptions) => ({ - sparse: field.unique && fieldIsLocalized(field), - unique: (!buildSchemaOptions.disableUnique && field.unique) || false, - required: false, - index: field.index || field.unique || false, -}); + if ((schema.unique && (field.localized || buildSchemaOptions.draftsEnabled))) { + schema.sparse = true; + } + + return schema; +}; const localizeSchema = (field: NonPresentationalField | Tab, schema, localization) => { if (fieldIsLocalized(field) && localization && Array.isArray(localization.locales)) { @@ -65,7 +67,6 @@ const localizeSchema = (field: NonPresentationalField | Tab, schema, localizatio _id: false, }), localized: true, - index: schema.index, }; } return schema; @@ -75,9 +76,7 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema const { allowIDField, options } = buildSchemaOptions; let fields = {}; - let schemaFields = configFields; - const indexFields: Index[] = []; if (!allowIDField) { const idField = schemaFields.find((field) => fieldAffectsData(field) && field.name === 'id'); @@ -85,7 +84,7 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema fields = { _id: idField.type === 'number' ? Number : String, }; - schemaFields = schemaFields.filter((field) => fieldAffectsData(field) && field.name !== 'id'); + schemaFields = schemaFields.filter((field) => !(fieldAffectsData(field) && field.name === 'id')); } } @@ -98,58 +97,13 @@ const buildSchema = (config: SanitizedConfig, configFields: Field[], buildSchema if (addFieldSchema) { addFieldSchema(field, schema, config, buildSchemaOptions); } - - // geospatial field index must be created after the schema is created - if (fieldIndexMap[field.type]) { - indexFields.push(...fieldIndexMap[field.type](field, config)); - } - - if (config.indexSortableFields && !buildSchemaOptions.global && !field.index && !field.hidden && sortableFieldTypes.indexOf(field.type) > -1 && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 } }); - } else if (field.unique && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 }, options: { unique: true, sparse: field.localized || false } }); - } else if (field.index && fieldAffectsData(field)) { - indexFields.push({ index: { [field.name]: 1 } }); - } } }); - if (buildSchemaOptions?.options?.timestamps) { - indexFields.push({ index: { createdAt: 1 } }); - indexFields.push({ index: { updatedAt: 1 } }); - } - - indexFields.forEach((indexField) => { - schema.index(indexField.index, indexField.options); - }); - return schema; }; -const fieldIndexMap = { - point: (field: PointField, config: SanitizedConfig) => { - let direction: boolean | '2dsphere'; - const options: IndexOptions = { - unique: field.unique || false, - sparse: (field.localized && field.unique) || false, - }; - if (field.index === true || field.index === undefined) { - direction = '2dsphere'; - } - if (field.localized && config.localization) { - return config.localization.locales.map((locale) => ({ - index: { [`${field.name}.${locale}`]: direction }, - options, - })); - } - if (field.unique) { - options.unique = true; - } - return [{ index: { [field.name]: direction }, options }]; - }, -}; - -const fieldToSchemaMap = { +const fieldToSchemaMap: Record = { number: (field: NumberField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Number }; @@ -192,24 +146,40 @@ const fieldToSchemaMap = { [field.name]: localizeSchema(field, baseSchema, config.localization), }); }, - point: (field: PointField, schema: Schema, config: SanitizedConfig): void => { - const baseSchema = { + point: (field: PointField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + const baseSchema: SchemaTypeOptions = { type: { type: String, enum: ['Point'], }, coordinates: { type: [Number], - sparse: field.unique && field.localized, - unique: field.unique || false, required: false, default: field.defaultValue || undefined, }, }; + if (buildSchemaOptions.disableUnique && field.unique && field.localized) { + baseSchema.coordinates.sparse = true; + } schema.add({ [field.name]: localizeSchema(field, baseSchema, config.localization), }); + + if (field.index === true || field.index === undefined) { + const indexOptions: IndexOptions = {}; + if (!buildSchemaOptions.disableUnique && field.unique) { + indexOptions.sparse = true; + indexOptions.unique = true; + } + if (field.localized && config.localization) { + config.localization.locales.forEach((locale) => { + schema.index({ [`${field.name}.${locale}`]: '2dsphere' }, indexOptions); + }); + } else { + schema.index({ [field.name]: '2dsphere' }, indexOptions); + } + } }, radio: (field: RadioField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema = { @@ -355,11 +325,15 @@ const fieldToSchemaMap = { array: (field: ArrayField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions) => { const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), - type: [buildSchema(config, field.fields, { - options: { _id: false, id: false }, - allowIDField: true, - disableUnique: buildSchemaOptions.disableUnique, - })], + type: [buildSchema( + config, + field.fields, + { + options: { _id: false, id: false }, + allowIDField: true, + disableUnique: buildSchemaOptions.disableUnique, + }, + )], }; schema.add({ @@ -374,14 +348,18 @@ const fieldToSchemaMap = { const baseSchema = { ...formattedBaseSchema, - required: required && field.fields.some((subField) => (!fieldIsPresentationalOnly(subField) && subField.required && !fieldIsLocalized(subField) && !subField?.admin?.condition && !subField?.access?.create)), - type: buildSchema(config, field.fields, { - options: { - _id: false, - id: false, + required: required && field.fields.some((subField) => (!fieldIsPresentationalOnly(subField) && subField.required && !subField.localized && !subField?.admin?.condition && !subField?.access?.create)), + type: buildSchema( + config, + field.fields, + { + options: { + _id: false, + id: false, + }, + disableUnique: buildSchemaOptions.disableUnique, }, - disableUnique: buildSchemaOptions.disableUnique, - }), + ), }; schema.add({ @@ -405,19 +383,9 @@ const fieldToSchemaMap = { }, blocks: (field: BlockField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const fieldSchema = [new Schema({}, { _id: false, discriminatorKey: 'blockType' })]; - let schemaToReturn; - - if (field.localized && config.localization) { - schemaToReturn = config.localization.locales.reduce((localeSchema, locale) => ({ - ...localeSchema, - [locale]: fieldSchema, - }), {}); - } else { - schemaToReturn = fieldSchema; - } schema.add({ - [field.name]: schemaToReturn, + [field.name]: localizeSchema(field, fieldSchema, config.localization), }); field.blocks.forEach((blockItem: Block) => { diff --git a/src/mongoose/sanitizeFormattedValue.ts b/src/mongoose/sanitizeFormattedValue.ts index 102ba11be4..d538a472e2 100644 --- a/src/mongoose/sanitizeFormattedValue.ts +++ b/src/mongoose/sanitizeFormattedValue.ts @@ -1,6 +1,7 @@ import mongoose, { SchemaType } from 'mongoose'; import { createArrayFromCommaDelineated } from './createArrayFromCommaDelineated'; import { getSchemaTypeOptions } from './getSchemaTypeOptions'; +import wordBoundariesRegex from '../utilities/wordBoundariesRegex'; export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operator: string, val: any): unknown => { let formattedValue = val; @@ -96,12 +97,8 @@ export const sanitizeQueryValue = (schemaType: SchemaType, path: string, operato } if (operator === 'like' && typeof formattedValue === 'string') { - const words = formattedValue.split(' '); - const regex = words.reduce((pattern, word, i) => { - return `${pattern}(?=.*\\b${word}.*\\b)${i + 1 === words.length ? '.+' : ''}`; - }, ''); - - formattedValue = { $regex: new RegExp(regex), $options: 'i' }; + const $regex = wordBoundariesRegex(formattedValue); + formattedValue = { $regex }; } } diff --git a/src/types/index.ts b/src/types/index.ts index 27df091e4a..7bfd57a1ba 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,7 +4,9 @@ import { FileData } from '../uploads/types'; export { PayloadRequest } from '../express/types'; -export type Operator = 'equals' +export type Operator = + | 'equals' + | 'contains' | 'not_equals' | 'in' | 'not_in' diff --git a/src/uploads/getBaseFields.ts b/src/uploads/getBaseFields.ts index 4681590d71..6deff4a3fb 100644 --- a/src/uploads/getBaseFields.ts +++ b/src/uploads/getBaseFields.ts @@ -92,6 +92,8 @@ const getBaseUploadFields = ({ config, collection }: Options): Field[] => { filename, mimeType, filesize, + width, + height, ]; if (uploadOptions.mimeTypes) { @@ -100,8 +102,6 @@ const getBaseUploadFields = ({ config, collection }: Options): Field[] => { if (uploadOptions.imageSizes) { uploadFields = uploadFields.concat([ - width, - height, { name: 'sizes', label: 'Sizes', diff --git a/src/utilities/arrayMove.ts b/src/utilities/arrayMove.ts new file mode 100644 index 0000000000..a6c694eedb --- /dev/null +++ b/src/utilities/arrayMove.ts @@ -0,0 +1,9 @@ +export function arrayMove(array: readonly T[], from: number, to: number) { + const slicedArray = array.slice(); + slicedArray.splice( + to < 0 ? array.length + to : to, + 0, + slicedArray.splice(from, 1)[0] + ); + return slicedArray; +} diff --git a/src/utilities/getIDType.ts b/src/utilities/getIDType.ts new file mode 100644 index 0000000000..ecae09c472 --- /dev/null +++ b/src/utilities/getIDType.ts @@ -0,0 +1,8 @@ +import { Field } from '../fields/config/types'; + +export const getIDType = (idField: Field | null): 'number' | 'text' | 'ObjectID' => { + if (idField) { + return idField.type === 'number' ? 'number' : 'text'; + } + return 'ObjectID'; +}; diff --git a/src/utilities/isValidID.ts b/src/utilities/isValidID.ts new file mode 100644 index 0000000000..036995730b --- /dev/null +++ b/src/utilities/isValidID.ts @@ -0,0 +1,9 @@ +import ObjectID from 'bson-objectid'; + +export const isValidID = (value: string | number, type: 'text' | 'number' | 'ObjectID'): boolean => { + if (type === 'ObjectID') { + return ObjectID.isValid(String(value)); + } + return (type === 'text' && typeof value === 'string') + || (type === 'number' && typeof value === 'number' && !Number.isNaN(value)); +}; diff --git a/src/utilities/logger.ts b/src/utilities/logger.ts index 841aa79f46..7a18ddab83 100644 --- a/src/utilities/logger.ts +++ b/src/utilities/logger.ts @@ -1,4 +1,3 @@ -import falsey from 'falsey'; import pino from 'pino'; import memoize from 'micro-memoize'; @@ -7,7 +6,7 @@ export type PayloadLogger = pino.Logger; export default memoize( (name = 'payload', options?: pino.LoggerOptions) => pino({ name, - enabled: falsey(process.env.DISABLE_LOGGING), + enabled: process.env.DISABLE_LOGGING !== 'true', ...(options ? { options } : { diff --git a/src/utilities/wordBoundariesRegex.ts b/src/utilities/wordBoundariesRegex.ts new file mode 100644 index 0000000000..a5d8d38450 --- /dev/null +++ b/src/utilities/wordBoundariesRegex.ts @@ -0,0 +1,12 @@ +export default (input: string): RegExp => { + const words = input.split(' '); + + // Regex word boundaries that work for cyrillic characters - https://stackoverflow.com/a/47062016/1717697 + const wordBoundaryBefore = '(?:(?<=[^\\p{L}\\p{N}])|^)'; + const wordBoundaryAfter = '(?=[^\\p{L}\\p{N}]|$)'; + + const regex = words.reduce((pattern, word, i) => { + return `${pattern}(?=.*${wordBoundaryBefore}${word}.*${wordBoundaryAfter})${i + 1 === words.length ? '.+' : ''}`; + }, ''); + return new RegExp(regex, 'i'); +}; diff --git a/test/access-control/config.ts b/test/access-control/config.ts index 55cb9f155b..3cc59faf8a 100644 --- a/test/access-control/config.ts +++ b/test/access-control/config.ts @@ -9,6 +9,7 @@ export const readOnlySlug = 'read-only-collection'; export const restrictedSlug = 'restricted'; export const restrictedVersionsSlug = 'restricted-versions'; export const siblingDataSlug = 'sibling-data'; +export const relyOnRequestHeadersSlug = 'rely-on-request-headers'; const openAccess = { create: () => true, @@ -24,6 +25,11 @@ const PublicReadabilityAccess: FieldAccess = ({ req: { user }, siblingData }) => return false; }; +export const requestHeaders = {authorization: 'Bearer testBearerToken'}; +const UseRequestHeadersAccess: FieldAccess = ({ req: { headers } }) => { + return !!headers && headers.authorization === requestHeaders.authorization; +}; + export default buildConfig({ collections: [ { @@ -115,6 +121,21 @@ export default buildConfig({ }, ], }, + { + slug: relyOnRequestHeadersSlug, + access: { + create: UseRequestHeadersAccess, + read: UseRequestHeadersAccess, + update: UseRequestHeadersAccess, + delete: UseRequestHeadersAccess, + }, + fields: [ + { + name: 'name', + type: 'text', + }, + ], + }, ], onInit: async (payload) => { await payload.create({ diff --git a/test/access-control/int.spec.ts b/test/access-control/int.spec.ts index b3a97db5ca..391d3fe3e0 100644 --- a/test/access-control/int.spec.ts +++ b/test/access-control/int.spec.ts @@ -1,9 +1,11 @@ import mongoose from 'mongoose'; import payload from '../../src'; +import type { Options as CreateOptions } from '../../src/collections/operations/local/create'; import { Forbidden } from '../../src/errors'; +import type { PayloadRequest } from '../../src/types'; import { initPayloadTest } from '../helpers/configHelpers'; -import { restrictedSlug, siblingDataSlug, slug } from './config'; -import type { Restricted, Post, SiblingDatum } from './payload-types'; +import { relyOnRequestHeadersSlug, requestHeaders, restrictedSlug, siblingDataSlug, slug } from './config'; +import type { Restricted, Post, SiblingDatum, RelyOnRequestHeader } from './payload-types'; import { firstArrayText, secondArrayText } from './shared'; describe('Access Control', () => { @@ -74,7 +76,7 @@ describe('Access Control', () => { describe('Collections', () => { describe('restricted collection', () => { it('field without read access should not show', async () => { - const { id } = await createDoc({ restrictedField: 'restricted' }); + const { id } = await createDoc({ restrictedField: 'restricted' }); const retrievedDoc = await payload.findByID({ collection: slug, id, overrideAccess: false }); @@ -82,7 +84,7 @@ describe('Access Control', () => { }); it('field without read access should not show when overrideAccess: true', async () => { - const { id, restrictedField } = await createDoc({ restrictedField: 'restricted' }); + const { id, restrictedField } = await createDoc({ restrictedField: 'restricted' }); const retrievedDoc = await payload.findByID({ collection: slug, id, overrideAccess: true }); @@ -90,13 +92,59 @@ describe('Access Control', () => { }); it('field without read access should not show when overrideAccess default', async () => { - const { id, restrictedField } = await createDoc({ restrictedField: 'restricted' }); + const { id, restrictedField } = await createDoc({ restrictedField: 'restricted' }); const retrievedDoc = await payload.findByID({ collection: slug, id }); expect(retrievedDoc.restrictedField).toEqual(restrictedField); }); }); + describe('non-enumerated request properties passed to access control', () => { + it('access control ok when passing request headers', async () => { + const req = Object.defineProperty({}, 'headers', { + value: requestHeaders, + enumerable: false, + }) as PayloadRequest; + const name = 'name'; + const overrideAccess = false; + + const { id } = await createDoc({ name }, relyOnRequestHeadersSlug, { req, overrideAccess }); + const docById = await payload.findByID({ collection: relyOnRequestHeadersSlug, id, req, overrideAccess }); + const { docs: docsByName } = await payload.find({ + collection: relyOnRequestHeadersSlug, + where: { + name: { + equals: name, + }, + }, + req, + overrideAccess, + }); + + expect(docById).not.toBeUndefined(); + expect(docsByName.length).toBeGreaterThan(0); + }); + + it('access control fails when omitting request headers', async () => { + const name = 'name'; + const overrideAccess = false; + + await expect(() => createDoc({ name }, relyOnRequestHeadersSlug, { overrideAccess })).rejects.toThrow(Forbidden); + const { id } = await createDoc({ name }, relyOnRequestHeadersSlug); + + await expect(() => payload.findByID({ collection: relyOnRequestHeadersSlug, id, overrideAccess })).rejects.toThrow(Forbidden); + + await expect(() => payload.find({ + collection: relyOnRequestHeadersSlug, + where: { + name: { + equals: name, + }, + }, + overrideAccess, + })).rejects.toThrow(Forbidden); + }); + }); }); describe('Override Access', () => { @@ -172,9 +220,10 @@ describe('Access Control', () => { }); }); -async function createDoc(data: Partial): Promise { - return payload.create({ - collection: slug, +async function createDoc(data: Partial, overrideSlug = slug, options?: Partial>): Promise { + return payload.create({ + ...options, + collection: overrideSlug, data: data ?? {}, }); } diff --git a/test/access-control/payload-types.ts b/test/access-control/payload-types.ts index 5aa051871a..cbbace518a 100644 --- a/test/access-control/payload-types.ts +++ b/test/access-control/payload-types.ts @@ -5,7 +5,7 @@ * and re-run `payload generate:types` to regenerate this file. */ -export interface Config {} +export interface Config { } /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "autosave-global". @@ -33,9 +33,21 @@ export interface AutosavePost { */ export interface DraftPost { id: string; - _status?: 'draft' | 'published'; - title: string; - description: string; + array: { + allowPublicReadability?: boolean; + text?: string; + id?: string; + }[]; + createdAt: string; + updatedAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "rely-on-request-headers". + */ +export interface RelyOnRequestHeader { + id: string; + name?: string; createdAt: string; updatedAt: string; } diff --git a/test/admin/config.ts b/test/admin/config.ts index 1dd9c8bc51..c8dd44225e 100644 --- a/test/admin/config.ts +++ b/test/admin/config.ts @@ -1,3 +1,4 @@ +import path from 'path'; import { mapAsync } from '../../src/utilities/mapAsync'; import { devUser } from '../credentials'; import { buildConfig } from '../buildConfig'; @@ -18,6 +19,7 @@ export interface Post { export default buildConfig({ admin: { + css: path.resolve(__dirname, 'styles.scss'), components: { // providers: [CustomProvider, CustomProvider], routes: [ diff --git a/test/admin/e2e.spec.ts b/test/admin/e2e.spec.ts index 35ec1306a2..e88b29bc44 100644 --- a/test/admin/e2e.spec.ts +++ b/test/admin/e2e.spec.ts @@ -120,17 +120,6 @@ describe('admin', () => { expect(page.url()).toContain(url.list); }); - test('should duplicate existing', async () => { - const { id } = await createPost(); - - await page.goto(url.edit(id)); - await page.locator('#action-duplicate').click(); - - expect(page.url()).toContain(url.create); - await page.locator('#action-save').click(); - expect(page.url()).not.toContain(id); // new id - }); - test('should save globals', async () => { await page.goto(url.global(globalSlug)); @@ -236,38 +225,39 @@ describe('admin', () => { }); }); - describe('sorting', () => { + describe('custom css', () => { + test('should see custom css in admin UI', async () => { + await page.goto(url.admin); + const navControls = await page.locator('.nav__controls'); + await expect(navControls).toHaveCSS('font-family', 'monospace'); + }); + }); + + // TODO: Troubleshoot flaky suite + describe.skip('sorting', () => { beforeAll(async () => { - [1, 2].map(async () => { - await createPost(); - }); + await createPost(); + await createPost(); }); test('should sort', async () => { - const getTableItems = () => page.locator(tableRowLocator); - - await expect(getTableItems()).toHaveCount(2); - const upChevron = page.locator('#heading-id .sort-column__asc'); const downChevron = page.locator('#heading-id .sort-column__desc'); - const getFirstId = async () => page.locator('.row-1 .cell-id').innerText(); - const getSecondId = async () => page.locator('.row-2 .cell-id').innerText(); + const firstId = await page.locator('.row-1 .cell-id').innerText(); + const secondId = await page.locator('.row-2 .cell-id').innerText(); - const firstId = await getFirstId(); - const secondId = await getSecondId(); - - await upChevron.click({ delay: 100 }); + await upChevron.click({ delay: 200 }); // Order should have swapped - expect(await getFirstId()).toEqual(secondId); - expect(await getSecondId()).toEqual(firstId); + expect(await page.locator('.row-1 .cell-id').innerText()).toEqual(secondId); + expect(await page.locator('.row-2 .cell-id').innerText()).toEqual(firstId); - await downChevron.click({ delay: 100 }); + await downChevron.click({ delay: 200 }); // Swap back - expect(await getFirstId()).toEqual(firstId); - expect(await getSecondId()).toEqual(secondId); + expect(await page.locator('.row-1 .cell-id').innerText()).toEqual(firstId); + expect(await page.locator('.row-2 .cell-id').innerText()).toEqual(secondId); }); }); }); diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index 8416d61e02..b1e2c80c45 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -6,6 +6,14 @@ */ export interface Config {} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "global". + */ +export interface Global { + id: string; + title?: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "posts". diff --git a/test/admin/styles.scss b/test/admin/styles.scss new file mode 100644 index 0000000000..85b0ab6e37 --- /dev/null +++ b/test/admin/styles.scss @@ -0,0 +1,6 @@ +.nav__controls { + font-family: monospace; +} +.nav__controls:before { + content: 'custom-css'; +} diff --git a/test/array-update/payload-types.ts b/test/array-update/payload-types.ts index 5dfead267f..4e850b84c3 100644 --- a/test/array-update/payload-types.ts +++ b/test/array-update/payload-types.ts @@ -12,7 +12,7 @@ export interface Config {} */ export interface Array { id: string; - array?: { + array: { required: string; optional?: string; id?: string; diff --git a/test/auth/payload-types.ts b/test/auth/payload-types.ts index fe69f84827..197be73123 100644 --- a/test/auth/payload-types.ts +++ b/test/auth/payload-types.ts @@ -12,6 +12,7 @@ export interface Config {} */ export interface User { id: string; + roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[]; enableAPIKey?: boolean; apiKey?: string; apiKeyIndex?: string; @@ -20,7 +21,6 @@ export interface User { resetPasswordExpiration?: string; loginAttempts?: number; lockUntil?: string; - roles: ('admin' | 'editor' | 'moderator' | 'user' | 'viewer')[]; createdAt: string; updatedAt: string; } diff --git a/test/buildConfig.ts b/test/buildConfig.ts index c4ea2888a0..3b5c9b76c4 100644 --- a/test/buildConfig.ts +++ b/test/buildConfig.ts @@ -3,9 +3,6 @@ import { Config, SanitizedConfig } from '../src/config/types'; import { buildConfig as buildPayloadConfig } from '../src/config/build'; const baseConfig: Config = { - typescript: { - outputFile: process.env.PAYLOAD_TS_OUTPUT_PATH, - }, telemetry: false, }; diff --git a/test/collections-rest/Endpoints/index.ts b/test/collections-rest/Endpoints/index.ts deleted file mode 100644 index 4221883c11..0000000000 --- a/test/collections-rest/Endpoints/index.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Response } from 'express'; -import { CollectionConfig } from '../../../src/collections/config/types'; -import { openAccess } from '../../helpers/configHelpers'; -import { PayloadRequest } from '../../../src/express/types'; - -export const endpointsSlug = 'endpoints'; - -const Endpoints: CollectionConfig = { - slug: endpointsSlug, - access: openAccess, - endpoints: [ - { - path: '/say-hello/joe-bloggs', - method: 'get', - handler: (req: PayloadRequest, res: Response): void => { - res.json({ message: 'Hey Joey!' }); - }, - }, - { - path: '/say-hello/:group/:name', - method: 'get', - handler: (req: PayloadRequest, res: Response): void => { - res.json({ message: `Hello ${req.params.name} @ ${req.params.group}` }); - }, - }, - { - path: '/say-hello/:name', - method: 'get', - handler: (req: PayloadRequest, res: Response): void => { - res.json({ message: `Hello ${req.params.name}!` }); - }, - }, - { - path: '/whoami', - method: 'post', - handler: (req: PayloadRequest, res: Response): void => { - res.json({ - name: req.body.name, - age: req.body.age, - }); - }, - }, - ], - fields: [ - { - name: 'title', - type: 'text', - }, - ], -}; - -export default Endpoints; diff --git a/test/collections-rest/config.ts b/test/collections-rest/config.ts index 9e114549a6..f541effc81 100644 --- a/test/collections-rest/config.ts +++ b/test/collections-rest/config.ts @@ -2,7 +2,6 @@ import type { CollectionConfig } from '../../src/collections/config/types'; import { devUser } from '../credentials'; import { buildConfig } from '../buildConfig'; import type { Post } from './payload-types'; -import Endpoints from './Endpoints'; export interface Relation { id: string; @@ -102,8 +101,13 @@ export default buildConfig({ type: 'text', }, { - name: 'name', - type: 'text', + type: 'row', + fields: [ + { + name: 'name', + type: 'text', + }, + ], }, ], }, @@ -121,7 +125,6 @@ export default buildConfig({ }, ], }, - Endpoints, ], onInit: async (payload) => { await payload.create({ @@ -197,5 +200,21 @@ export default buildConfig({ ], }, }); + + await payload.create({ + collection: customIdSlug, + data: { + id: 'test', + name: 'inside row', + }, + }); + + await payload.create({ + collection: customIdNumberSlug, + data: { + id: 123, + name: 'name', + }, + }); }, }); diff --git a/test/collections-rest/endpoints-int.spec.ts b/test/collections-rest/endpoints-int.spec.ts deleted file mode 100644 index 05ae6a7c92..0000000000 --- a/test/collections-rest/endpoints-int.spec.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { initPayloadTest } from '../helpers/configHelpers'; -import { endpointsSlug } from './Endpoints'; -import { RESTClient } from '../helpers/rest'; -import { slug } from '../globals/config'; - -require('isomorphic-fetch'); - -let client: RESTClient; - -describe('Collections - Endpoints', () => { - beforeAll(async () => { - const config = await initPayloadTest({ __dirname, init: { local: false } }); - const { serverURL } = config; - client = new RESTClient(config, { serverURL, defaultSlug: slug }); - }); - describe('Endpoints', () => { - it('should GET a static endpoint', async () => { - const { status, data } = await client.endpoint(`/${endpointsSlug}/say-hello/joe-bloggs`); - expect(status).toBe(200); - expect(data.message).toStrictEqual('Hey Joey!'); - }); - - it('should GET an endpoint with a parameter', async () => { - const name = 'George'; - const { status, data } = await client.endpoint(`/${endpointsSlug}/say-hello/${name}`); - expect(status).toBe(200); - expect(data.message).toStrictEqual(`Hello ${name}!`); - }); - - it('should POST an endpoint with data', async () => { - const params = { name: 'George', age: 29 }; - const { status, data } = await client.endpoint(`/${endpointsSlug}/whoami`, 'post', params); - expect(status).toBe(200); - expect(data.name).toStrictEqual(params.name); - expect(data.age).toStrictEqual(params.age); - }); - }); -}); diff --git a/test/collections-rest/int.spec.ts b/test/collections-rest/int.spec.ts index 270a031c21..32ae8493ec 100644 --- a/test/collections-rest/int.spec.ts +++ b/test/collections-rest/int.spec.ts @@ -66,13 +66,15 @@ describe('collections-rest', () => { describe('string', () => { it('should create', async () => { const customId = `custom-${randomBytes(32).toString('hex').slice(0, 12)}`; - const { doc } = await client.create({ slug: customIdSlug, data: { id: customId, data: { name: 'custom-id-name' } } }); + const customIdName = 'custom-id-name'; + const { doc } = await client.create({ slug: customIdSlug, data: { id: customId, name: customIdName } }); expect(doc.id).toEqual(customId); + expect(doc.name).toEqual(customIdName); }); it('should find', async () => { const customId = `custom-${randomBytes(32).toString('hex').slice(0, 12)}`; - const { doc } = await client.create({ slug: customIdSlug, data: { id: customId, data: { name: 'custom-id-name' } } }); + const { doc } = await client.create({ slug: customIdSlug, data: { id: customId, name: 'custom-id-name' } }); const { doc: foundDoc } = await client.findByID({ slug: customIdSlug, id: customId }); expect(foundDoc.id).toEqual(doc.id); @@ -89,20 +91,20 @@ describe('collections-rest', () => { describe('number', () => { it('should create', async () => { const customId = Math.floor(Math.random() * (1_000_000)) + 1; - const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, data: { name: 'custom-id-number-name' } } }); + const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, name: 'custom-id-number-name' } }); expect(doc.id).toEqual(customId); }); it('should find', async () => { const customId = Math.floor(Math.random() * (1_000_000)) + 1; - const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, data: { name: 'custom-id-number-name' } } }); + const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, name: 'custom-id-number-name' } }); const { doc: foundDoc } = await client.findByID({ slug: customIdNumberSlug, id: customId }); expect(foundDoc.id).toEqual(doc.id); }); it('should update', async () => { const customId = Math.floor(Math.random() * (1_000_000)) + 1; - const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, data: { name: 'custom-id-number-name' } } }); + const { doc } = await client.create({ slug: customIdNumberSlug, data: { id: customId, name: 'custom-id-number-name' } }); const { doc: updatedDoc } = await client.update({ slug: customIdNumberSlug, id: doc.id, data: { name: 'updated' } }); expect(updatedDoc.name).toEqual('updated'); }); @@ -371,6 +373,22 @@ describe('collections-rest', () => { expect(result.totalDocs).toEqual(1); }); + it('like - cyrillic characters', async () => { + const post1 = await createPost({ title: 'Тест' }); + + const { status, result } = await client.find({ + query: { + title: { + like: 'Тест', + }, + }, + }); + + expect(status).toEqual(200); + expect(result.docs).toEqual([post1]); + expect(result.totalDocs).toEqual(1); + }); + it('like - partial word match', async () => { const post = await createPost({ title: 'separate words should partially match' }); diff --git a/test/collections-rest/payload-types.ts b/test/collections-rest/payload-types.ts index c12c10847a..58116fe6d3 100644 --- a/test/collections-rest/payload-types.ts +++ b/test/collections-rest/payload-types.ts @@ -93,16 +93,6 @@ export interface CustomIdNumber { createdAt: string; updatedAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "endpoints". - */ -export interface Endpoint { - id: string; - title?: string; - createdAt: string; - updatedAt: string; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". diff --git a/test/dataloader/config.ts b/test/dataloader/config.ts new file mode 100644 index 0000000000..551a84b5a0 --- /dev/null +++ b/test/dataloader/config.ts @@ -0,0 +1,47 @@ +import { buildConfig } from '../buildConfig'; +import { devUser } from '../credentials'; + +export default buildConfig({ + collections: [ + { + slug: 'posts', + fields: [ + { + name: 'title', + type: 'text', + required: true, + }, + { + name: 'owner', + type: 'relationship', + relationTo: 'users', + hooks: { + beforeChange: [ + ({ req: { user } }) => user?.id, + ], + }, + }, + + ], + }, + ], + onInit: async (payload) => { + const user = await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }); + + await payload.create({ + user, + collection: 'posts', + data: postDoc, + }); + }, +}); + +export const postDoc = { + title: 'test post', +}; diff --git a/test/dataloader/int.spec.ts b/test/dataloader/int.spec.ts new file mode 100644 index 0000000000..32459eb88d --- /dev/null +++ b/test/dataloader/int.spec.ts @@ -0,0 +1,53 @@ +import { GraphQLClient } from 'graphql-request'; +import payload from '../../src'; +import { devUser } from '../credentials'; +import { initPayloadTest } from '../helpers/configHelpers'; +import { postDoc } from './config'; + +describe('dataloader', () => { + let serverURL; + beforeAll(async () => { + const init = await initPayloadTest({ __dirname, init: { local: false } }); + serverURL = init.serverURL; + }); + + describe('graphql', () => { + let client: GraphQLClient; + let token: string; + + beforeAll(async () => { + const url = `${serverURL}/api/graphql`; + client = new GraphQLClient(url); + + const loginResult = await payload.login({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }); + + if (loginResult.token) token = loginResult.token; + }); + + it('should allow querying via graphql', async () => { + const query = `query { + Posts { + docs { + title + owner { + email + } + } + } + }`; + + const response = await client.request(query, null, { + Authorization: `JWT ${token}`, + }); + + const { docs } = response.Posts; + expect(docs[0].title).toStrictEqual(postDoc.title); + }); + }); +}); diff --git a/test/dataloader/payload-types.ts b/test/dataloader/payload-types.ts new file mode 100644 index 0000000000..6a77cff1be --- /dev/null +++ b/test/dataloader/payload-types.ts @@ -0,0 +1,33 @@ +/* tslint:disable */ +/** + * This file was automatically generated by Payload CMS. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +export interface Config {} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "posts". + */ +export interface Post { + id: string; + title: string; + owner?: string | User; + createdAt: string; + updatedAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + email?: string; + resetPasswordToken?: string; + resetPasswordExpiration?: string; + loginAttempts?: number; + lockUntil?: string; + createdAt: string; + updatedAt: string; +} diff --git a/test/endpoints/config.ts b/test/endpoints/config.ts new file mode 100644 index 0000000000..171a3690c2 --- /dev/null +++ b/test/endpoints/config.ts @@ -0,0 +1,90 @@ +import { Response } from 'express'; +import { devUser } from '../credentials'; +import { buildConfig } from '../buildConfig'; +import { openAccess } from '../helpers/configHelpers'; +import { PayloadRequest } from '../../src/express/types'; + +export const collectionSlug = 'endpoints'; +export const globalSlug = 'global-endpoints'; + +export const globalEndpoint = 'global'; +export const applicationEndpoint = 'path'; + +export default buildConfig({ + collections: [ + { + slug: collectionSlug, + access: openAccess, + endpoints: [ + { + path: '/say-hello/joe-bloggs', + method: 'get', + handler: (req: PayloadRequest, res: Response): void => { + res.json({ message: 'Hey Joey!' }); + }, + }, + { + path: '/say-hello/:group/:name', + method: 'get', + handler: (req: PayloadRequest, res: Response): void => { + res.json({ message: `Hello ${req.params.name} @ ${req.params.group}` }); + }, + }, + { + path: '/say-hello/:name', + method: 'get', + handler: (req: PayloadRequest, res: Response): void => { + res.json({ message: `Hello ${req.params.name}!` }); + }, + }, + { + path: '/whoami', + method: 'post', + handler: (req: PayloadRequest, res: Response): void => { + res.json({ + name: req.body.name, + age: req.body.age, + }); + }, + }, + ], + fields: [ + { + name: 'title', + type: 'text', + }, + ], + }, + ], + globals: [ + { + slug: globalSlug, + endpoints: [{ + path: `/${globalEndpoint}`, + method: 'post', + handler: (req: PayloadRequest, res: Response): void => { + res.json(req.body); + }, + }], + fields: [], + }, + ], + endpoints: [ + { + path: `/${applicationEndpoint}`, + method: 'post', + handler: (req: PayloadRequest, res: Response): void => { + res.json(req.body); + }, + }, + ], + onInit: async (payload) => { + await payload.create({ + collection: 'users', + data: { + email: devUser.email, + password: devUser.password, + }, + }); + }, +}); diff --git a/test/endpoints/int.spec.ts b/test/endpoints/int.spec.ts new file mode 100644 index 0000000000..404f1dcf49 --- /dev/null +++ b/test/endpoints/int.spec.ts @@ -0,0 +1,58 @@ +import { initPayloadTest } from '../helpers/configHelpers'; +import { RESTClient } from '../helpers/rest'; +import { applicationEndpoint, collectionSlug, globalEndpoint, globalSlug } from './config'; + +require('isomorphic-fetch'); + +let client: RESTClient; + +describe('Endpoints', () => { + beforeAll(async () => { + const config = await initPayloadTest({ __dirname, init: { local: false } }); + const { serverURL } = config; + client = new RESTClient(config, { serverURL, defaultSlug: collectionSlug }); + }); + + describe('Collections', () => { + it('should GET a static endpoint', async () => { + const { status, data } = await client.endpoint(`/${collectionSlug}/say-hello/joe-bloggs`); + expect(status).toBe(200); + expect(data.message).toStrictEqual('Hey Joey!'); + }); + + it('should GET an endpoint with a parameter', async () => { + const name = 'George'; + const { status, data } = await client.endpoint(`/${collectionSlug}/say-hello/${name}`); + expect(status).toBe(200); + expect(data.message).toStrictEqual(`Hello ${name}!`); + }); + + it('should POST an endpoint with data', async () => { + const params = { name: 'George', age: 29 }; + const { status, data } = await client.endpoint(`/${collectionSlug}/whoami`, 'post', params); + expect(status).toBe(200); + expect(data.name).toStrictEqual(params.name); + expect(data.age).toStrictEqual(params.age); + }); + }); + + describe('Globals', () => { + it('should call custom endpoint', async () => { + const params = { globals: 'response' }; + const { status, data } = await client.endpoint(`/globals/${globalSlug}/${globalEndpoint}`, 'post', params); + + expect(status).toBe(200); + expect(params).toMatchObject(data); + }); + }); + + describe('API', () => { + it('should call custom endpoint', async () => { + const params = { app: 'response' }; + const { status, data } = await client.endpoint(`/${applicationEndpoint}`, 'post', params); + + expect(status).toBe(200); + expect(params).toMatchObject(data); + }); + }); +}); diff --git a/test/endpoints/payload-types.ts b/test/endpoints/payload-types.ts new file mode 100644 index 0000000000..fee2200bda --- /dev/null +++ b/test/endpoints/payload-types.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/** + * This file was automatically generated by Payload CMS. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +export interface Config {} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "global-endpoints". + */ +export interface GlobalEndpoints { + id: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "endpoints". + */ +export interface Endpoint { + id: string; + title?: string; + createdAt: string; + updatedAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + email?: string; + resetPasswordToken?: string; + resetPasswordExpiration?: string; + loginAttempts?: number; + lockUntil?: string; + createdAt: string; + updatedAt: string; +} diff --git a/test/fields-relationship/config.ts b/test/fields-relationship/config.ts index d197f893c2..1ad43455eb 100644 --- a/test/fields-relationship/config.ts +++ b/test/fields-relationship/config.ts @@ -128,7 +128,7 @@ export default buildConfig({ }); const relationOneIDs = []; - await mapAsync([...Array(5)], async () => { + await mapAsync([...Array(11)], async () => { const doc = await payload.create({ collection: relationOneSlug, data: { @@ -156,18 +156,22 @@ export default buildConfig({ name: 'relation-restricted', }, }); - const { id: relationWithTitleDocId } = await payload.create({ - collection: relationWithTitleSlug, - data: { - name: 'relation-title', - }, + const relationsWithTitle = []; + await mapAsync(['relation-title', 'word boundary search'], async (title) => { + const { id } = await payload.create({ + collection: relationWithTitleSlug, + data: { + name: title, + }, + }); + relationsWithTitle.push(id); }); await payload.create({ collection: slug, data: { relationship: relationOneDocId, relationshipRestricted: restrictedDocId, - relationshipWithTitle: relationWithTitleDocId, + relationshipWithTitle: relationsWithTitle[0], }, }); await mapAsync([...Array(11)], async () => { diff --git a/test/fields-relationship/e2e.spec.ts b/test/fields-relationship/e2e.spec.ts index 45ac25bc11..8c096a55ba 100644 --- a/test/fields-relationship/e2e.spec.ts +++ b/test/fields-relationship/e2e.spec.ts @@ -81,6 +81,14 @@ describe('fields - relationship', () => { }, }); + // Doc with useAsTitle for word boundary test + await payload.create({ + collection: relationWithTitleSlug, + data: { + name: 'word boundary search', + }, + }); + // Add restricted doc as relation docWithExistingRelations = await payload.create({ collection: slug, @@ -190,7 +198,25 @@ describe('fields - relationship', () => { }); // test.todo('should paginate within the dropdown'); - // test.todo('should search within the relationship field'); + + test('should search within the relationship field', async () => { + await page.goto(url.edit(docWithExistingRelations.id)); + const input = page.locator('#field-relationshipWithTitle input'); + await input.fill('title'); + const options = page.locator('#field-relationshipWithTitle .rs__menu .rs__option'); + await expect(options).toHaveCount(1); + + await input.fill('non-occuring-string'); + await expect(options).toHaveCount(0); + }); + + test('should search using word boundaries within the relationship field', async () => { + await page.goto(url.edit(docWithExistingRelations.id)); + const input = page.locator('#field-relationshipWithTitle input'); + await input.fill('word search'); + const options = page.locator('#field-relationshipWithTitle .rs__menu .rs__option'); + await expect(options).toHaveCount(1); + }); test('should show useAsTitle on relation', async () => { await page.goto(url.edit(docWithExistingRelations.id)); @@ -203,7 +229,7 @@ describe('fields - relationship', () => { await field.click({ delay: 100 }); const options = page.locator('.rs__option'); - await expect(options).toHaveCount(2); // None + 1 Doc + await expect(options).toHaveCount(3); // None + 2 Doc }); test('should show id on relation in list view', async () => { diff --git a/test/fields-relationship/payload-types.ts b/test/fields-relationship/payload-types.ts index 5185244292..e668538a58 100644 --- a/test/fields-relationship/payload-types.ts +++ b/test/fields-relationship/payload-types.ts @@ -78,32 +78,6 @@ export interface RelationWithTitle { createdAt: string; updatedAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "group-nested-relation-with-title". - */ -export interface GroupNestedRelationWithTitle { - id: string; - group?: { - relation?: string | NestedRelationWithTitle; - }; - createdAt: string; - updatedAt: string; -} -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "nested-relation-with-title". - */ -export interface NestedRelationWithTitle { - id: string; - group?: { - subGroup?: { - relation?: string | RelationOne; - }; - }; - createdAt: string; - updatedAt: string; -} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". diff --git a/test/fields/collections/Blocks/index.ts b/test/fields/collections/Blocks/index.ts index d8b8c091cd..6bee37e487 100644 --- a/test/fields/collections/Blocks/index.ts +++ b/test/fields/collections/Blocks/index.ts @@ -14,6 +14,10 @@ export const blocksField: Field = { type: 'text', required: true, }, + { + name: 'richText', + type: 'richText', + }, ], }, { @@ -63,12 +67,55 @@ export const blocksField: Field = { }, ], }, + { + slug: 'tabs', + fields: [ + { + type: 'tabs', + tabs: [ + { + label: 'Tab with Collapsible', + fields: [ + { + type: 'collapsible', + label: 'Collapsible within Block', + fields: [ + { + // collapsible + name: 'textInCollapsible', + type: 'text', + }, + ], + }, + { + type: 'row', + fields: [ + { + // collapsible + name: 'textInRow', + type: 'text', + }, + ], + }, + ], + }, + ], + }, + ], + }, ], }; const BlockFields: CollectionConfig = { slug: 'block-fields', - fields: [blocksField], + fields: [ + blocksField, + { + ...blocksField, + name: 'localizedBlocks', + localized: true, + }, + ], }; export const blocksFieldSeedData = [ @@ -76,6 +123,7 @@ export const blocksFieldSeedData = [ blockName: 'First block', blockType: 'text', text: 'first block', + richText: [], }, { blockName: 'Second block', @@ -102,6 +150,7 @@ export const blocksFieldSeedData = [ export const blocksDoc = { blocks: blocksFieldSeedData, + localizedBlocks: blocksFieldSeedData, }; export default BlockFields; diff --git a/test/fields/collections/Code/index.tsx b/test/fields/collections/Code/index.tsx new file mode 100644 index 0000000000..837f5385cc --- /dev/null +++ b/test/fields/collections/Code/index.tsx @@ -0,0 +1,126 @@ +import type { CollectionConfig } from '../../../../src/collections/config/types'; +import { CodeField } from '../../payload-types'; + +const Code: CollectionConfig = { + slug: 'code-fields', + fields: [ + { + name: 'javascript', + type: 'code', + admin: { + language: 'js', + }, + }, + { + name: 'typescript', + type: 'code', + admin: { + language: 'ts', + }, + }, + { + name: 'json', + type: 'code', + admin: { + language: 'json', + }, + }, + { + name: 'html', + type: 'code', + admin: { + language: 'html', + }, + }, + { + name: 'css', + type: 'code', + admin: { + language: 'css', + }, + }, + ], +}; + +export const codeDoc: Partial = { + javascript: "console.log('Hello');", + typescript: `class Greeter { + greeting: string; + + constructor(message: string) { + this.greeting = message; + } + + greet() { + return "Hello, " + this.greeting; + } +} + +let greeter = new Greeter("world");`, + + html: ` + + + + + + +Prism + + + + + + + +`, + + css: `@import url(https://fonts.googleapis.com/css?family=Questrial); +@import url(https://fonts.googleapis.com/css?family=Arvo); + +@font-face { + src: url(https://lea.verou.me/logo.otf); + font-family: 'LeaVerou'; +} + +/* + Shared styles + */ + +section h1, +#features li strong, +header h2, +footer p { + font: 100% Rockwell, Arvo, serif; +} + +/* + Styles + */ + +* { + margin: 0; + padding: 0; +} + +body { + font: 100%/1.5 Questrial, sans-serif; + tab-size: 4; + hyphens: auto; +} + +a { + color: inherit; +} + +section h1 { + font-size: 250%; +}`, + + json: JSON.stringify({ property: 'value', arr: ['val1', 'val2', 'val3'] }, null, 2), +}; + +export default Code; diff --git a/test/fields/collections/Date/index.ts b/test/fields/collections/Date/index.ts new file mode 100644 index 0000000000..3d3882bb3e --- /dev/null +++ b/test/fields/collections/Date/index.ts @@ -0,0 +1,63 @@ +import type { CollectionConfig } from '../../../../src/collections/config/types'; + +export const defaultText = 'default-text'; + +const DateFields: CollectionConfig = { + slug: 'date-fields', + admin: { + useAsTitle: 'date', + }, + fields: [ + { + name: 'default', + type: 'date', + required: true, + }, + { + name: 'timeOnly', + type: 'date', + admin: { + date: { + pickerAppearance: 'timeOnly', + }, + }, + }, + { + name: 'dayOnly', + type: 'date', + admin: { + date: { + pickerAppearance: 'dayOnly', + }, + }, + }, + { + name: 'dayAndTime', + type: 'date', + admin: { + date: { + pickerAppearance: 'dayAndTime', + }, + }, + }, + { + name: 'monthOnly', + type: 'date', + admin: { + date: { + pickerAppearance: 'monthOnly', + }, + }, + }, + ], +}; + +export const dateDoc = { + default: '2022-08-12T10:00:00.000+00:00', + timeOnly: '2022-08-12T10:00:00.157+00:00', + dayOnly: '2022-08-11T22:00:00.000+00:00', + dayAndTime: '2022-08-12T10:00:00.052+00:00', + monthOnly: '2022-07-31T22:00:00.000+00:00', +}; + +export default DateFields; diff --git a/test/fields/collections/Indexed/index.ts b/test/fields/collections/Indexed/index.ts index 08d91f2678..139c28d010 100644 --- a/test/fields/collections/Indexed/index.ts +++ b/test/fields/collections/Indexed/index.ts @@ -34,6 +34,24 @@ const IndexedFields: CollectionConfig = { }, ], }, + { + type: 'collapsible', + label: 'Collapsible', + fields: [ + { + name: 'collapsibleLocalizedUnique', + type: 'text', + unique: true, + localized: true, + }, + { + name: 'collapsibleTextUnique', + type: 'text', + label: 'collapsibleTextUnique', + unique: true, + }, + ], + }, ], }; diff --git a/test/fields/collections/Point/index.ts b/test/fields/collections/Point/index.ts index 9032dc90aa..ef051e20ea 100644 --- a/test/fields/collections/Point/index.ts +++ b/test/fields/collections/Point/index.ts @@ -19,6 +19,7 @@ const PointFields: CollectionConfig = { name: 'localized', type: 'point', label: 'Localized Point', + unique: true, localized: true, }, { @@ -36,7 +37,7 @@ const PointFields: CollectionConfig = { export const pointDoc = { point: [7, -7], - localized: [5, -2], + localized: [15, -12], group: { point: [1, 9] }, }; diff --git a/test/fields/collections/RichText/index.ts b/test/fields/collections/RichText/index.ts index 538a4c1a34..579d3ea864 100644 --- a/test/fields/collections/RichText/index.ts +++ b/test/fields/collections/RichText/index.ts @@ -43,6 +43,22 @@ const RichTextFields: CollectionConfig = { type: 'richText', required: true, admin: { + link: { + fields: [ + { + name: 'rel', + label: 'Rel Attribute', + type: 'select', + hasMany: true, + options: [ + 'noopener', 'noreferrer', 'nofollow', + ], + admin: { + description: 'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.', + }, + }, + ], + }, upload: { collections: { uploads: { @@ -78,7 +94,7 @@ export const richTextDoc = { }, { type: 'link', - url: 'test.com', + url: 'https://payloadcms.com', newTab: true, children: [ { @@ -87,7 +103,24 @@ export const richTextDoc = { ], }, { - text: ' and store nested relationship fields:', + text: ', ', + }, + { + type: 'link', + linkType: 'internal', + doc: { + value: '{{ARRAY_DOC_ID}}', + relationTo: 'array-fields', + }, + fields: {}, + children: [ + { + text: 'link to relationships', + }, + ], + }, + { + text: ', and store nested relationship fields:', }, ], }, diff --git a/test/fields/collections/Select/index.ts b/test/fields/collections/Select/index.ts index c2296d6c9d..abf04f5991 100644 --- a/test/fields/collections/Select/index.ts +++ b/test/fields/collections/Select/index.ts @@ -30,6 +30,7 @@ const SelectFields: CollectionConfig = { type: 'select', admin: { isClearable: true, + isSortable: true, }, options: [ { diff --git a/test/fields/collections/Text/index.ts b/test/fields/collections/Text/index.ts index 9d932239e9..58c662d286 100644 --- a/test/fields/collections/Text/index.ts +++ b/test/fields/collections/Text/index.ts @@ -13,6 +13,11 @@ const TextFields: CollectionConfig = { type: 'text', required: true, }, + { + name: 'localizedText', + type: 'text', + localized: true, + }, { name: 'defaultFunction', type: 'text', @@ -32,6 +37,7 @@ const TextFields: CollectionConfig = { export const textDoc = { text: 'Seeded text document', + localizedText: 'Localized text', }; export default TextFields; diff --git a/test/fields/config.ts b/test/fields/config.ts index f361cef2de..24c618cf77 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import path from 'path'; import fs from 'fs'; import { buildConfig } from '../buildConfig'; @@ -6,6 +7,7 @@ import ArrayFields, { arrayDoc } from './collections/Array'; import BlockFields, { blocksDoc } from './collections/Blocks'; import CollapsibleFields, { collapsibleDoc } from './collections/Collapsible'; import ConditionalLogic, { conditionalLogicDoc } from './collections/ConditionalLogic'; +import DateFields, { dateDoc } from './collections/Date'; import RichTextFields, { richTextDoc } from './collections/RichText'; import SelectFields, { selectsDoc } from './collections/Select'; import TabsFields, { tabsDoc } from './collections/Tabs'; @@ -16,6 +18,7 @@ import getFileByPath from '../../src/uploads/getFileByPath'; import Uploads, { uploadsDoc } from './collections/Upload'; import IndexedFields from './collections/Indexed'; import NumberFields, { numberDoc } from './collections/Number'; +import CodeFields, { codeDoc } from './collections/Code'; export default buildConfig({ admin: { @@ -33,6 +36,7 @@ export default buildConfig({ collections: [ ArrayFields, BlockFields, + CodeFields, CollapsibleFields, ConditionalLogic, GroupFields, @@ -44,6 +48,7 @@ export default buildConfig({ NumberFields, Uploads, IndexedFields, + DateFields, ], localization: { defaultLocale: 'en', @@ -58,14 +63,15 @@ export default buildConfig({ }, }); - await payload.create({ collection: 'array-fields', data: arrayDoc }); - await payload.create({ collection: 'block-fields', data: blocksDoc }); + const createdArrayDoc = await payload.create({ collection: 'array-fields', data: arrayDoc }); await payload.create({ collection: 'collapsible-fields', data: collapsibleDoc }); await payload.create({ collection: 'conditional-logic', data: conditionalLogicDoc }); await payload.create({ collection: 'group-fields', data: groupDoc }); await payload.create({ collection: 'select-fields', data: selectsDoc }); await payload.create({ collection: 'tabs-fields', data: tabsDoc }); await payload.create({ collection: 'point-fields', data: pointDoc }); + await payload.create({ collection: 'date-fields', data: dateDoc }); + await payload.create({ collection: 'code-fields', data: codeDoc }); const createdTextDoc = await payload.create({ collection: 'text-fields', data: textDoc }); @@ -78,7 +84,8 @@ export default buildConfig({ const createdUploadDoc = await payload.create({ collection: 'uploads', data: uploadsDoc, file }); - const richTextDocWithRelationship = { ...richTextDoc }; + const richTextDocWithRelId = JSON.parse(JSON.stringify(richTextDoc).replace('{{ARRAY_DOC_ID}}', createdArrayDoc.id)); + const richTextDocWithRelationship = { ...richTextDocWithRelId }; const richTextRelationshipIndex = richTextDocWithRelationship.richText.findIndex(({ type }) => type === 'relationship'); richTextDocWithRelationship.richText[richTextRelationshipIndex].value = { id: createdTextDoc.id }; @@ -89,5 +96,14 @@ export default buildConfig({ await payload.create({ collection: 'rich-text-fields', data: richTextDocWithRelationship }); await payload.create({ collection: 'number-fields', data: numberDoc }); + + const blocksDocWithRichText = { ...blocksDoc }; + + // @ts-ignore + blocksDocWithRichText.blocks[0].richText = richTextDocWithRelationship.richText; + // @ts-ignore + blocksDocWithRichText.localizedBlocks[0].richText = richTextDocWithRelationship.richText; + + await payload.create({ collection: 'block-fields', data: blocksDocWithRichText }); }, }); diff --git a/test/fields/e2e.spec.ts b/test/fields/e2e.spec.ts index afe043f94f..f311012a91 100644 --- a/test/fields/e2e.spec.ts +++ b/test/fields/e2e.spec.ts @@ -139,4 +139,75 @@ describe('fields', () => { await expect(page.locator('#field-textInRow')).toHaveValue(textInRowValue); }); }); + + describe('fields - richText', () => { + test('should create new url link', async () => { + const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields'); + await page.goto(url.list); + await page.locator('.row-1 .cell-id').click(); + + // Open link popup + await page.locator('.rich-text__toolbar .link').click(); + + const editLinkModal = page.locator('.rich-text-link-edit-modal__template'); + await expect(editLinkModal).toBeVisible(); + + // Fill values and click Confirm + await editLinkModal.locator('#field-text').fill('link text'); + await editLinkModal.locator('label[for="field-linkType-custom"]').click(); + await editLinkModal.locator('#field-url').fill('https://payloadcms.com'); + await wait(200); + await editLinkModal.locator('button[type="submit"]').click(); + + // Remove link + await page.locator('span >> text="link text"').click(); + const popup = page.locator('.popup--active .rich-text-link__popup'); + await expect(popup.locator('.rich-text-link__link-label')).toBeVisible(); + await popup.locator('.rich-text-link__link-close').click(); + await expect(page.locator('span >> text="link text"')).toHaveCount(0); + }); + + test('should populate url link', async () => { + const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields'); + await page.goto(url.list); + await page.locator('.row-1 .cell-id').click(); + + // Open link popup + await page.locator('span >> text="render links"').click(); + const popup = page.locator('.popup--active .rich-text-link__popup'); + await expect(popup).toBeVisible(); + await expect(popup.locator('a')).toHaveAttribute('href', 'https://payloadcms.com'); + + // Open link edit modal + await popup.locator('.rich-text-link__link-edit').click(); + const editLinkModal = page.locator('.rich-text-link-edit-modal__template'); + await expect(editLinkModal).toBeVisible(); + + // Close link edit modal + await editLinkModal.locator('button[type="submit"]').click(); + await expect(editLinkModal).not.toBeVisible(); + }); + + test('should populate relationship link', async () => { + const url: AdminUrlUtil = new AdminUrlUtil(serverURL, 'rich-text-fields'); + await page.goto(url.list); + await page.locator('.row-1 .cell-id').click(); + + // Open link popup + await page.locator('span >> text="link to relationships"').click(); + const popup = page.locator('.popup--active .rich-text-link__popup'); + await expect(popup).toBeVisible(); + await expect(popup.locator('a')).toHaveAttribute('href', /\/admin\/collections\/array-fields\/.*/); + + // Open link edit modal + await popup.locator('.rich-text-link__link-edit').click(); + const editLinkModal = page.locator('.rich-text-link-edit-modal__template'); + await expect(editLinkModal).toBeVisible(); + + // Close link edit modal + await editLinkModal.locator('button[type="submit"]').click(); + await expect(editLinkModal).not.toBeVisible(); + // await page.locator('span >> text="render links"').click(); + }); + }); }); diff --git a/test/fields/int.spec.ts b/test/fields/int.spec.ts index 0855b68599..be3f613f47 100644 --- a/test/fields/int.spec.ts +++ b/test/fields/int.spec.ts @@ -120,6 +120,9 @@ describe('Fields', () => { const options: Record = {}; beforeAll(() => { + // mongoose model schema indexes do not always create indexes in the actual database + // see: https://github.com/payloadcms/payload/issues/571 + indexes = payload.collections['indexed-fields'].Model.schema.indexes() as [Record, IndexOptions]; indexes.forEach((index) => { @@ -149,10 +152,19 @@ describe('Fields', () => { expect(definitions['group.localizedUnique.es']).toEqual(1); expect(options['group.localizedUnique.es']).toMatchObject({ unique: true, sparse: true }); }); + it('should have unique indexes in a collapsible', () => { + expect(definitions['collapsibleLocalizedUnique.en']).toEqual(1); + expect(options['collapsibleLocalizedUnique.en']).toMatchObject({ unique: true, sparse: true }); + expect(definitions.collapsibleTextUnique).toEqual(1); + expect(options.collapsibleTextUnique).toMatchObject({ unique: true }); + }); }); describe('point', () => { let doc; + const point = [7, -7]; + const localized = [5, -2]; + const group = { point: [1, 9] }; beforeAll(async () => { const findDoc = await payload.find({ @@ -176,9 +188,6 @@ describe('Fields', () => { }); it('should create', async () => { - const point = [7, -7]; - const localized = [5, -2]; - const group = { point: [1, 9] }; doc = await payload.create({ collection: 'point-fields', data: { @@ -192,6 +201,30 @@ describe('Fields', () => { expect(doc.localized).toEqual(localized); expect(doc.group).toMatchObject(group); }); + + it('should not create duplicate point when unique', async () => { + await expect(() => payload.create({ + collection: 'point-fields', + data: { + point, + localized, + group, + }, + })) + .rejects + .toThrow(Error); + + await expect(async () => payload.create({ + collection: 'number-fields', + data: { + min: 5, + }, + })).rejects.toThrow('The following field is invalid: min'); + + expect(doc.point).toEqual(point); + expect(doc.localized).toEqual(localized); + expect(doc.group).toMatchObject(group); + }); }); describe('array', () => { let doc; @@ -359,6 +392,78 @@ describe('Fields', () => { expect(blockFields.docs[0].blocks[2].subBlocks[0].number).toEqual(blocksFieldSeedData[2].subBlocks[0].number); expect(blockFields.docs[0].blocks[2].subBlocks[1].text).toEqual(blocksFieldSeedData[2].subBlocks[1].text); }); + + it('should query based on richtext data within a block', async () => { + const blockFieldsSuccess = await payload.find({ + collection: 'block-fields', + where: { + 'blocks.richText.children.text': { + like: 'fun', + }, + }, + }); + + expect(blockFieldsSuccess.docs).toHaveLength(1); + + const blockFieldsFail = await payload.find({ + collection: 'block-fields', + where: { + 'blocks.richText.children.text': { + like: 'funny', + }, + }, + }); + + expect(blockFieldsFail.docs).toHaveLength(0); + }); + + it('should query based on richtext data within a localized block, specifying locale', async () => { + const blockFieldsSuccess = await payload.find({ + collection: 'block-fields', + where: { + 'localizedBlocks.en.richText.children.text': { + like: 'fun', + }, + }, + }); + + expect(blockFieldsSuccess.docs).toHaveLength(1); + + const blockFieldsFail = await payload.find({ + collection: 'block-fields', + where: { + 'localizedBlocks.en.richText.children.text': { + like: 'funny', + }, + }, + }); + + expect(blockFieldsFail.docs).toHaveLength(0); + }); + + it('should query based on richtext data within a localized block, without specifying locale', async () => { + const blockFieldsSuccess = await payload.find({ + collection: 'block-fields', + where: { + 'localizedBlocks.richText.children.text': { + like: 'fun', + }, + }, + }); + + expect(blockFieldsSuccess.docs).toHaveLength(1); + + const blockFieldsFail = await payload.find({ + collection: 'block-fields', + where: { + 'localizedBlocks.richText.children.text': { + like: 'funny', + }, + }, + }); + + expect(blockFieldsFail.docs).toHaveLength(0); + }); }); describe('richText', () => { @@ -385,5 +490,28 @@ describe('Fields', () => { expect(workingRichTextQuery.docs).toHaveLength(1); }); + + it('should populate link relationship', async () => { + const query = await payload.find({ + collection: 'rich-text-fields', + where: { + 'richText.children.linkType': { + equals: 'internal', + }, + }, + }); + + const nodes = query.docs[0].richText; + expect(nodes).toBeDefined(); + const child = nodes.flatMap((n) => n.children) + .find((c) => c.doc); + expect(child).toMatchObject({ + type: 'link', + linkType: 'internal', + }); + expect(child.doc.relationTo).toEqual('array-fields'); + expect(typeof child.doc.value.id).toBe('string'); + expect(child.doc.value.items).toHaveLength(6); + }); }); }); diff --git a/test/fields/payload-types.ts b/test/fields/payload-types.ts index 7b0f6cbf5c..fbb64ed0f3 100644 --- a/test/fields/payload-types.ts +++ b/test/fields/payload-types.ts @@ -40,6 +40,9 @@ export interface BlockField { blocks: ( | { text: string; + richText?: { + [k: string]: unknown; + }[]; id?: string; blockName?: string; blockType: 'text'; @@ -70,6 +73,56 @@ export interface BlockField { blockType: 'subBlocks'; } )[]; + localizedBlocks: ( + | { + text: string; + richText?: { + [k: string]: unknown; + }[]; + id?: string; + blockName?: string; + blockType: 'text'; + } + | { + number: number; + id?: string; + blockName?: string; + blockType: 'number'; + } + | { + subBlocks: ( + | { + text: string; + id?: string; + blockName?: string; + blockType: 'text'; + } + | { + number: number; + id?: string; + blockName?: string; + blockType: 'number'; + } + )[]; + id?: string; + blockName?: string; + blockType: 'subBlocks'; + } + )[]; + createdAt: string; + updatedAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "code-fields". + */ +export interface CodeField { + id: string; + javascript?: string; + typescript?: string; + json?: string; + html?: string; + css?: string; createdAt: string; updatedAt: string; } @@ -188,6 +241,9 @@ export interface TabsField { blocks: ( | { text: string; + richText?: { + [k: string]: unknown; + }[]; id?: string; blockName?: string; blockType: 'text'; @@ -235,6 +291,7 @@ export interface TabsField { export interface TextField { id: string; text: string; + localizedText?: string; defaultFunction?: string; defaultAsync?: string; createdAt: string; @@ -292,6 +349,22 @@ export interface IndexedField { */ point?: [number, number]; }; + collapsibleLocalizedUnique?: string; + collapsibleTextUnique?: string; + createdAt: string; + updatedAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "date-fields". + */ +export interface DateField { + id: string; + default: string; + timeOnly?: string; + dayOnly?: string; + dayAndTime?: string; + monthOnly?: string; createdAt: string; updatedAt: string; } diff --git a/test/globals/config.ts b/test/globals/config.ts index 031db91751..e4cd5841ab 100644 --- a/test/globals/config.ts +++ b/test/globals/config.ts @@ -1,7 +1,5 @@ -import { Response } from 'express'; import { devUser } from '../credentials'; import { buildConfig } from '../buildConfig'; -import { PayloadRequest } from '../../src/express/types'; export const slug = 'global'; export const arraySlug = 'array'; @@ -31,13 +29,6 @@ export default buildConfig({ type: 'text', }, ], - endpoints: [{ - path: `/${globalsEndpoint}`, - method: 'post', - handler: (req: PayloadRequest, res: Response): void => { - res.json(req.body); - }, - }], }, { slug: arraySlug, diff --git a/test/globals/int.spec.ts b/test/globals/int.spec.ts index 847678c628..2a43ffefee 100644 --- a/test/globals/int.spec.ts +++ b/test/globals/int.spec.ts @@ -1,6 +1,6 @@ import { GraphQLClient } from 'graphql-request'; import { initPayloadTest } from '../helpers/configHelpers'; -import config, { arraySlug, englishLocale, globalsEndpoint, slug, spanishLocale } from './config'; +import config, { arraySlug, englishLocale, slug, spanishLocale } from './config'; import payload from '../../src'; import { RESTClient } from '../helpers/rest'; @@ -56,16 +56,6 @@ describe('globals', () => { expect(doc.array).toMatchObject(array); expect(doc.id).toBeDefined(); }); - - describe('Endpoints', () => { - it('should call custom endpoint', async () => { - const params = { globals: 'response' }; - const { status, data } = await client.endpoint(`/globals/${slug}/${globalsEndpoint}`, 'post', params); - - expect(status).toBe(200); - expect(params).toMatchObject(data); - }); - }); }); describe('local', () => { diff --git a/test/globals/payload-types.ts b/test/globals/payload-types.ts new file mode 100644 index 0000000000..a73ed769e2 --- /dev/null +++ b/test/globals/payload-types.ts @@ -0,0 +1,41 @@ +/* tslint:disable */ +/** + * This file was automatically generated by Payload CMS. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +export interface Config {} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "global". + */ +export interface Global { + id: string; + title?: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "array". + */ +export interface Array { + id: string; + array: { + text?: string; + id?: string; + }[]; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + email?: string; + resetPasswordToken?: string; + resetPasswordExpiration?: string; + loginAttempts?: number; + lockUntil?: string; + createdAt: string; + updatedAt: string; +} diff --git a/test/helpers/rest.ts b/test/helpers/rest.ts index b5c7f6aee8..3188a4b27c 100644 --- a/test/helpers/rest.ts +++ b/test/helpers/rest.ts @@ -174,7 +174,7 @@ export class RESTClient { const response = await fetch(`${this.serverURL}/api/${slug || this.defaultSlug}/${id}${formattedQs}`, { body: JSON.stringify(data), headers, - method: 'put', + method: 'PATCH', }); const { status } = response; const json = await response.json(); diff --git a/test/hooks/collections/Hook/index.ts b/test/hooks/collections/Hook/index.ts index 54c47930c6..2d71dfd0ea 100644 --- a/test/hooks/collections/Hook/index.ts +++ b/test/hooks/collections/Hook/index.ts @@ -1,11 +1,15 @@ /* eslint-disable no-param-reassign */ import { CollectionConfig } from '../../../../src/collections/config/types'; -import { openAccess } from '../../../helpers/configHelpers'; export const hooksSlug = 'hooks'; const Hooks: CollectionConfig = { slug: hooksSlug, - access: openAccess, + access: { + read: () => true, + create: () => true, + delete: () => true, + update: () => true, + }, hooks: { beforeValidate: [({ data }) => validateHookOrder('collectionBeforeValidate', data)], beforeChange: [({ data }) => validateHookOrder('collectionBeforeChange', data)], diff --git a/test/hooks/collections/Transform/index.ts b/test/hooks/collections/Transform/index.ts index 0b7cabe0ea..6c92d51398 100644 --- a/test/hooks/collections/Transform/index.ts +++ b/test/hooks/collections/Transform/index.ts @@ -1,6 +1,5 @@ /* eslint-disable no-param-reassign */ import { CollectionConfig } from '../../../../src/collections/config/types'; -import { openAccess } from '../../../helpers/configHelpers'; const validateFieldTransformAction = (hook: string, value) => { if (value !== undefined && value !== null && !Array.isArray(value)) { @@ -12,7 +11,12 @@ const validateFieldTransformAction = (hook: string, value) => { export const transformSlug = 'transforms'; const TransformHooks: CollectionConfig = { slug: transformSlug, - access: openAccess, + access: { + read: () => true, + create: () => true, + delete: () => true, + update: () => true, + }, fields: [ { name: 'transform', diff --git a/test/localization/e2e.spec.ts b/test/localization/e2e.spec.ts index df362cb798..39642e8384 100644 --- a/test/localization/e2e.spec.ts +++ b/test/localization/e2e.spec.ts @@ -1,11 +1,13 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +import payload from '../../src'; import type { TypeWithTimestamps } from '../../src/collections/config/types'; import { AdminUrlUtil } from '../helpers/adminUrlUtil'; import { initPayloadTest } from '../helpers/configHelpers'; import { login, saveDocAndAssert } from '../helpers'; import type { LocalizedPost } from './payload-types'; import { slug } from './config'; +import { englishTitle, spanishLocale } from './shared'; /** * TODO: Localization @@ -93,6 +95,40 @@ describe('Localization', () => { await expect(page.locator('#field-description')).toHaveValue(description); }); }); + + describe('localized duplicate', () => { + let id; + + beforeAll(async () => { + const localizedPost = await payload.create({ + collection: slug, + data: { + title: englishTitle, + }, + }); + id = localizedPost.id; + await payload.update({ + collection: slug, + id, + locale: spanishLocale, + data: { + title: spanishTitle, + }, + }); + }); + + test('should duplicate data for all locales', async () => { + await page.goto(url.edit(id)); + + await page.locator('.btn.duplicate').first().click(); + await expect(page.locator('.Toastify')).toContainText('successfully'); + + await expect(page.locator('#field-title')).toHaveValue(englishTitle); + + await changeLocale(spanishLocale); + await expect(page.locator('#field-title')).toHaveValue(spanishTitle); + }); + }); }); async function fillValues(data: Partial>) { diff --git a/test/localization/int.spec.ts b/test/localization/int.spec.ts index 9e97e6b90f..b3c02f6534 100644 --- a/test/localization/int.spec.ts +++ b/test/localization/int.spec.ts @@ -230,7 +230,7 @@ describe('Localization', () => { const result = await payload.find({ collection: withLocalizedRelSlug, where: { - 'localizedRelation.title': { + 'localizedRelationship.title': { equals: localizedRelation.title, }, }, @@ -244,7 +244,7 @@ describe('Localization', () => { collection: withLocalizedRelSlug, locale: spanishLocale, where: { - 'localizedRelation.title': { + 'localizedRelationship.title': { equals: relationSpanishTitle, }, }, @@ -258,7 +258,7 @@ describe('Localization', () => { collection: withLocalizedRelSlug, locale: 'all', where: { - 'localizedRelation.title.es': { + 'localizedRelationship.title.es': { equals: relationSpanishTitle, }, }, @@ -561,6 +561,55 @@ describe('Localization', () => { expect(typeof result.user.relation.title).toStrictEqual('string'); }); + + it('should create and update collections', async () => { + const url = `${serverURL}${config.routes.api}${config.routes.graphQL}`; + const client = new GraphQLClient(url); + + const create = `mutation { + createLocalizedPost( + data: { + title: "${englishTitle}" + } + locale: ${defaultLocale} + ) { + id + title + } + }`; + + const { createLocalizedPost: createResult } = await client.request(create, null, { + Authorization: `JWT ${token}`, + }); + + + const update = `mutation { + updateLocalizedPost( + id: "${createResult.id}", + data: { + title: "${spanishTitle}" + } + locale: ${spanishLocale} + ) { + title + } + }`; + + const { updateLocalizedPost: updateResult } = await client.request(update, null, { + Authorization: `JWT ${token}`, + }); + + const result = await payload.findByID({ + collection: slug, + id: createResult.id, + locale: 'all', + }); + + expect(createResult.title).toStrictEqual(englishTitle); + expect(updateResult.title).toStrictEqual(spanishTitle); + expect(result.title[defaultLocale]).toStrictEqual(englishTitle); + expect(result.title[spanishLocale]).toStrictEqual(spanishTitle); + }); }); }); diff --git a/test/localization/payload-types.ts b/test/localization/payload-types.ts index 8717f40832..6bfc1edbe9 100644 --- a/test/localization/payload-types.ts +++ b/test/localization/payload-types.ts @@ -6,6 +6,21 @@ */ export interface Config {} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + relation?: string | LocalizedPost; + email?: string; + resetPasswordToken?: string; + resetPasswordExpiration?: string; + loginAttempts?: number; + lockUntil?: string; + createdAt: string; + updatedAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "localized-posts". @@ -81,17 +96,3 @@ export interface Dummy { createdAt: string; updatedAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "users". - */ -export interface User { - id: string; - email?: string; - resetPasswordToken?: string; - resetPasswordExpiration?: string; - loginAttempts?: number; - lockUntil?: string; - createdAt: string; - updatedAt: string; -} diff --git a/test/relationships/int.spec.ts b/test/relationships/int.spec.ts index e278237bcc..e23c52b4ae 100644 --- a/test/relationships/int.spec.ts +++ b/test/relationships/int.spec.ts @@ -168,6 +168,35 @@ describe('Relationships', () => { const { doc } = await client.findByID({ id: post.id }); expect(doc?.customIdNumberRelation).toMatchObject({ id: generatedCustomIdNumber }); }); + + it('should validate the format of text id relationships', async () => { + await expect(async () => createPost({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Sending bad data to test error handling + customIdRelation: 1234, + })).rejects.toThrow('The following field is invalid: customIdRelation'); + }); + + it('should validate the format of number id relationships', async () => { + await expect(async () => createPost({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Sending bad data to test error handling + customIdNumberRelation: 'bad-input', + })).rejects.toThrow('The following field is invalid: customIdNumberRelation'); + }); + + it('should allow update removing a relationship', async () => { + const result = await client.update({ + slug, + id: post.id, + data: { + relationField: null, + }, + }); + + expect(result.status).toEqual(200); + expect(result.doc.relationField).toBeNull(); + }); }); describe('depth', () => { diff --git a/test/uploads/int.spec.ts b/test/uploads/int.spec.ts index 18e941e58c..e580310e0b 100644 --- a/test/uploads/int.spec.ts +++ b/test/uploads/int.spec.ts @@ -4,7 +4,7 @@ import FormData from 'form-data'; import { promisify } from 'util'; import { initPayloadTest } from '../helpers/configHelpers'; import { RESTClient } from '../helpers/rest'; -import config, { mediaSlug } from './config'; +import config, { mediaSlug, relationSlug } from './config'; import payload from '../../src'; import getFileByPath from '../../src/uploads/getFileByPath'; @@ -133,6 +133,35 @@ describe('Collections - Uploads', () => { expect(await fileExists(path.join(__dirname, './media', mediaDoc.sizes.icon.filename))).toBe(true); }); + it('should allow update removing a relationship', async () => { + const filePath = path.resolve(__dirname, './image.png'); + const file = getFileByPath(filePath); + file.name = 'renamed.png'; + + const { id } = await payload.create({ + collection: mediaSlug, + data: {}, + file, + }); + + const related = await payload.create({ + collection: relationSlug, + data: { + image: id, + }, + }); + + const doc = await payload.update({ + collection: relationSlug, + id: related.id, + data: { + image: null, + }, + }); + + expect(doc.image).toBeNull(); + }); + it('delete', async () => { const formData = new FormData(); formData.append('file', fs.createReadStream(path.join(__dirname, './image.png'))); diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index 7a846af56d..8c8ceb6ed4 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -28,8 +28,8 @@ export interface Media { filesize?: number; width?: number; height?: number; - sizes?: { - maintainedAspectRatio?: { + sizes: { + maintainedAspectRatio: { url?: string; width?: number; height?: number; @@ -37,7 +37,7 @@ export interface Media { filesize?: number; filename?: string; }; - tablet?: { + tablet: { url?: string; width?: number; height?: number; @@ -45,7 +45,7 @@ export interface Media { filesize?: number; filename?: string; }; - mobile?: { + mobile: { url?: string; width?: number; height?: number; @@ -53,7 +53,7 @@ export interface Media { filesize?: number; filename?: string; }; - icon?: { + icon: { url?: string; width?: number; height?: number; @@ -65,6 +65,19 @@ export interface Media { createdAt: string; updatedAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "unstored-media". + */ +export interface UnstoredMedia { + id: string; + url?: string; + filename?: string; + mimeType?: string; + filesize?: number; + createdAt: string; + updatedAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". diff --git a/test/versions/int.spec.ts b/test/versions/int.spec.ts index 6d6dca189e..7bca5a3e95 100644 --- a/test/versions/int.spec.ts +++ b/test/versions/int.spec.ts @@ -62,7 +62,7 @@ describe('Versions', () => { collectionLocalPostID = autosavePost.id; - const updatedPost = await payload.update({ + const updatedPost = await payload.update({ id: collectionLocalPostID, collection, data: { @@ -82,6 +82,35 @@ describe('Versions', () => { expect(collectionLocalVersionID).toBeDefined(); }); + it('should allow saving multiple versions of models with unique fields', async () => { + const autosavePost = await payload.create({ + collection, + data: { + title: 'unique unchanging title', + description: 'description 1', + }, + }); + + await payload.update({ + id: autosavePost.id, + collection, + data: { + description: 'description 2', + }, + }); + const finalDescription = 'final description'; + + const secondUpdate = await payload.update({ + id: autosavePost.id, + collection, + data: { + description: finalDescription, + }, + }); + + expect(secondUpdate.description).toBe(finalDescription); + }); + it('should allow a version to be retrieved by ID', async () => { const version = await payload.findVersionByID({ collection, diff --git a/test/versions/payload-types.ts b/test/versions/payload-types.ts index 545c3ef3f1..07ed78d49d 100644 --- a/test/versions/payload-types.ts +++ b/test/versions/payload-types.ts @@ -8,10 +8,34 @@ export interface Config {} /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "slugname". + * via the `definition` "autosave-global". */ -export interface Slugname { +export interface AutosaveGlobal { id: string; + title: string; + _status?: 'draft' | 'published'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "autosave-posts". + */ +export interface AutosavePost { + id: string; + title: string; + description: string; + _status?: 'draft' | 'published'; + createdAt: string; + updatedAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "draft-posts". + */ +export interface DraftPost { + id: string; + title: string; + description: string; + _status?: 'draft' | 'published'; createdAt: string; updatedAt: string; } diff --git a/types.d.ts b/types.d.ts index ba811ff270..1b2c685cb5 100644 --- a/types.d.ts +++ b/types.d.ts @@ -34,4 +34,25 @@ export { RichTextCustomElement, RichTextCustomLeaf, Block, + TextField, + NumberField, + EmailField, + TextareaField, + CheckboxField, + DateField, + BlockField, + GroupField, + RadioField, + RelationshipField, + ArrayField, + RichTextField, + SelectField, + UploadField, + CodeField, + PointField, + RowField, + CollapsibleField, + TabsField, + UIField, + Validate, } from './dist/fields/config/types'; diff --git a/yarn.lock b/yarn.lock index f42ecedf39..0c70a7a250 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1006,7 +1006,7 @@ core-js-pure "^3.20.2" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.9", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.9", "@babel/runtime@^7.2.0", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.18.9" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== @@ -1271,12 +1271,13 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz#8eed982e2ee6f7f4e44c253e12962980791efd46" integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA== -"@faceless-ui/modal@^1.1.7": - version "1.2.0" - resolved "https://registry.npmjs.org/@faceless-ui/modal/-/modal-1.2.0.tgz#0ca43e480f83d307dcd84c033fbc82c0619f5d8c" - integrity sha512-92LQw1ZIaphzCVaHyhxrzbRtn9LXnm5GOJVXJ4tDUpuz7j1B05QTSOuYWjBd8AZKsBR0MQhgr11BVVgJ70DEhw== +"@faceless-ui/modal@^2.0.0-alpha.4": + version "2.0.0-alpha.4" + resolved "https://registry.yarnpkg.com/@faceless-ui/modal/-/modal-2.0.0-alpha.4.tgz#f47c373433f186dc4b7e85c3e310562db3420eaa" + integrity sha512-v2b+vPhswX7ZBVQXdziUr89qst2ZdshLDQE8No/9LeGnQAo1TmNw1zPDuCBXF6Xi0gmHO6yUyfMFwFzUPcZl3Q== dependencies: body-scroll-lock "^3.1.5" + focus-trap "^6.9.2" qs "^6.9.1" react-transition-group "^4.4.2" @@ -6013,6 +6014,13 @@ flatted@^2.0.0: resolved "https://registry.npmjs.org/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== +focus-trap@^6.9.2: + version "6.9.4" + resolved "https://registry.npmjs.org/focus-trap/-/focus-trap-6.9.4.tgz#436da1a1d935c48b97da63cd8f361c6f3aa16444" + integrity sha512-v2NTsZe2FF59Y+sDykKY+XjqZ0cPfhq/hikWVL88BqLivnNiEffAsac6rP6H45ff9wG9LL5ToiDqrLEP9GX9mw== + dependencies: + tabbable "^5.3.3" + follow-redirects@^1.14.0: version "1.15.1" resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" @@ -6889,6 +6897,13 @@ interpret@^2.2.0: resolved "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw== +invariant@^2.2.4: + version "2.2.4" + resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" + integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== + dependencies: + loose-envify "^1.0.0" + ip@^1.1.5: version "1.1.8" resolved "https://registry.npmjs.org/ip/-/ip-1.1.8.tgz#ae05948f6b075435ed3307acce04629da8cdbf48" @@ -8658,7 +8673,7 @@ mongodb@4.8.1: optionalDependencies: saslprep "^1.0.3" -mongodb@^3.6.2, mongodb@^3.7.3: +mongodb@^3.7.3: version "3.7.3" resolved "https://registry.npmjs.org/mongodb/-/mongodb-3.7.3.tgz#b7949cfd0adc4cc7d32d3f2034214d4475f175a5" integrity sha512-Psm+g3/wHXhjBEktkxXsFMZvd3nemI0r3IPsE0bU+4//PnvNWKkzhZcEsbPcYiWqe8XqXJJEg4Tgtr7Raw67Yw== @@ -10218,7 +10233,7 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -10531,7 +10546,7 @@ react-router-dom@^5.1.2: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" -react-router-navigation-prompt@^1.8.11: +react-router-navigation-prompt@^1.9.6: version "1.9.6" resolved "https://registry.npmjs.org/react-router-navigation-prompt/-/react-router-navigation-prompt-1.9.6.tgz#a949252dfbae8c40508671beb6d5995f0b089ac4" integrity sha512-l0sAtbroHK8i1/Eyy29XcrMpBEt0R08BaScgMUt8r5vWWbLz7G0ChOikayTCQm7QgDFsHw8gVnxDJb7TBZCAKg== @@ -10576,6 +10591,15 @@ react-simple-code-editor@^0.11.0: resolved "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.11.2.tgz#af9da6706b76d2a520bd7b82e3383b329f61cd87" integrity sha512-vLMEDj+qLrZ88zK/8bhRdqM2Mp0cQJCUbHPc/QIfxlIYHzAclaAzPX0TQ4ZI5LTYSP3hsYYOT3EciV2dCG4o0Q== +react-sortable-hoc@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/react-sortable-hoc/-/react-sortable-hoc-2.0.0.tgz#f6780d8aa4b922a21f3e754af542f032677078b7" + integrity sha512-JZUw7hBsAHXK7PTyErJyI7SopSBFRcFHDjWW5SWjcugY0i6iH7f+eJkY8cJmGMlZ1C9xz1J3Vjz0plFpavVeRg== + dependencies: + "@babel/runtime" "^7.2.0" + invariant "^2.2.4" + prop-types "^15.5.7" + react-toastify@^8.2.0: version "8.2.0" resolved "https://registry.npmjs.org/react-toastify/-/react-toastify-8.2.0.tgz#ef7d56bdfdc6272ca6b228368ab564721c3a3244" @@ -11874,6 +11898,11 @@ symbol-tree@^3.2.4: resolved "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tabbable@^5.3.3: + version "5.3.3" + resolved "https://registry.npmjs.org/tabbable/-/tabbable-5.3.3.tgz#aac0ff88c73b22d6c3c5a50b1586310006b47fbf" + integrity sha512-QD9qKY3StfbZqWOPLp0++pOrAVb/HbUi5xCc8cUo4XjP19808oaMiDzn0leBY5mCespIBM0CIZePzZjgzR83kA== + table@^5.2.3: version "5.4.6" resolved "https://registry.npmjs.org/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e"