diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d384148fb..dd9bba6b89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,45 @@ +# [0.13.0](https://github.com/payloadcms/payload/compare/v0.12.3...v0.13.0) (2021-11-26) + + +### Bug Fixes + +* [#351](https://github.com/payloadcms/payload/issues/351) ([94c2b8d](https://github.com/payloadcms/payload/commit/94c2b8d80b046c067057d4ad089ed6a2edd656cf)) +* [#358](https://github.com/payloadcms/payload/issues/358) - reuploading with existing filenames ([a0fb48c](https://github.com/payloadcms/payload/commit/a0fb48c9a37beceafc6f0638604e9946d0814635)) +* allows sync or async preview urls ([da6e1df](https://github.com/payloadcms/payload/commit/da6e1df293ce46bc4d0c84645db61feea2881aa7)) +* bug with relationship cell when no doc is available ([40b33d9](https://github.com/payloadcms/payload/commit/40b33d9f5e99285cb0de148dbe059259817fcad8)) +* ensures 'like' query param remains functional in all cases ([20d4e72](https://github.com/payloadcms/payload/commit/20d4e72a951dfcbf1cc301d0938e5095932436b9)) +* ensures buildQuery works with fields as well as simultaneous or / and ([72fc413](https://github.com/payloadcms/payload/commit/72fc413764c6c42ba64a45f01d99b68ad3bd46c4)) +* ensures non-localized relationships with many relationTos can be queried ([7050b52](https://github.com/payloadcms/payload/commit/7050b5285e65a709a0bdfb68e8403839ef75151f)) +* ensures relationship field search can return more than 10 options ([57c0346](https://github.com/payloadcms/payload/commit/57c0346a00286a3df695ea46e5c2630494183b5b)) +* ensures richtext links retain proper formatting ([abf61d0](https://github.com/payloadcms/payload/commit/abf61d0734c09fd0fc5c5b827cb0631e11701f71)) +* ensures tquerying by relationship subpaths works ([37b21b0](https://github.com/payloadcms/payload/commit/37b21b07628e892e85c2cf979d9e2c8af0d291f7)) +* ensures uploads can be fetched with CORS ([96421b3](https://github.com/payloadcms/payload/commit/96421b3d59a87f8a3d781005c02344fe5d3a607f)) +* issue with querying by id and using comma-separated values ([d9e1b5e](https://github.com/payloadcms/payload/commit/d9e1b5ede33616893f9ba6990eeccf5410b75ae1)) +* typing for collection description ([bb18e82](https://github.com/payloadcms/payload/commit/bb18e8250c5742d9615e5780c1cd02d33ecca3d0)) +* updates field description type to include react nodes ([291c193](https://github.com/payloadcms/payload/commit/291c193ad4a9ec8ce9310cc63c714eba10eca102)) + + +### Features + +* add id fields to generated types ([21a810c](https://github.com/payloadcms/payload/commit/21a810c38c16e6907069d9076abf55718e439497)) +* adds field types to type generation ([6dd1b0e](https://github.com/payloadcms/payload/commit/6dd1b0e0339490bf9e500f03f47ed59381644d85)) +* adds relationship filter field ([463c4e6](https://github.com/payloadcms/payload/commit/463c4e60de8e647fca6268b826d826f9c6e45412)) +* applies upload access control to all auto-generated image sizes ([051b7d4](https://github.com/payloadcms/payload/commit/051b7d45befc331af3f73a669b2bb6467505902f)) +* azure cosmos compatibility ([6fd5ac2](https://github.com/payloadcms/payload/commit/6fd5ac2c082a5a5e6f510d781b2a2e12b7b62cb9)) +* baseline type generation ([5a965d2](https://github.com/payloadcms/payload/commit/5a965d2263d1b246149c5220d039a6d97953d5e8)) +* ensures update hooks have access to full original docs even in spite of access control ([b2c5b7e](https://github.com/payloadcms/payload/commit/b2c5b7e5752e829c7a53c054decceb43ec33065e)) +* finishes typing all fields ([ed5a5eb](https://github.com/payloadcms/payload/commit/ed5a5ebe7e16d20577556ab233032a52bef6d2cb)) +* further types field based functions ([6b150e0](https://github.com/payloadcms/payload/commit/6b150e01d306fde5686156a2fa6a8fa00aeffe0e)) +* generates further field types ([2ca76ba](https://github.com/payloadcms/payload/commit/2ca76ba8ce6c61ee7b0a01a557b6f013ca483d6d)) +* improves querying logic ([4c85747](https://github.com/payloadcms/payload/commit/4c8574784995b1cb1f939648f4d2158286089b3d)) +* indexes filenames ([5d43262](https://github.com/payloadcms/payload/commit/5d43262f42e0529a44572f398aa1ec5fd7858286)) +* migrates admin preview to async ([40ca3da](https://github.com/payloadcms/payload/commit/40ca3dae61f8ddf05363b6cad426deba4cde1e30)) +* more typing of generics, better commenting of properties ([820b6ad](https://github.com/payloadcms/payload/commit/820b6ad4c777ff3197091f323ae0ba9993df28f5)) +* renames useFieldType to useField ([0245747](https://github.com/payloadcms/payload/commit/0245747020c7c039b15d055f54a4548a364d047e)) +* smarter generics ([b99eb8b](https://github.com/payloadcms/payload/commit/b99eb8ba739b903c7012ae69a51af6f77295cfa3)) +* supports custom onChange handling in text, select, and upload fields ([4affdc3](https://github.com/payloadcms/payload/commit/4affdc3a9397d70f5baacdd12753c8fc8c7d8368)) +* type payload operation calls with generics ([f258c59](https://github.com/payloadcms/payload/commit/f258c5904eb98db5784d4b6cdf1324736c1fe88d)) + ## [0.12.13-beta.0](https://github.com/payloadcms/payload/compare/v0.12.3...v0.12.13-beta.0) (2021-11-24) ### Bug Fixes diff --git a/components/forms.ts b/components/forms.ts index 39abf0bfb2..ba81e00716 100644 --- a/components/forms.ts +++ b/components/forms.ts @@ -6,7 +6,8 @@ export { useFormModified, } from '../dist/admin/components/forms/Form/context'; -export { default as useFieldType } from '../dist/admin/components/forms/useFieldType'; +export { default as useField } from '../dist/admin/components/forms/useField'; +export { default as useFieldType } from '../dist/admin/components/forms/useField'; export { default as Form } from '../dist/admin/components/forms/Form'; diff --git a/demo/client/components/richText/elements/Button/index.ts b/demo/client/components/richText/elements/Button/index.ts index 4cde513af1..1a4afaecbf 100644 --- a/demo/client/components/richText/elements/Button/index.ts +++ b/demo/client/components/richText/elements/Button/index.ts @@ -1,8 +1,9 @@ +import { RichTextCustomElement } from '../../../../../../dist/fields/config/types'; import Button from './Button'; import Element from './Element'; import plugin from './plugin'; -export default { +const button: RichTextCustomElement = { name: 'button', Button, Element, @@ -10,3 +11,5 @@ export default { plugin, ], }; + +export default button; diff --git a/demo/collections/CustomComponents/components/fields/Select/Field/index.tsx b/demo/collections/CustomComponents/components/fields/Select/Field/index.tsx new file mode 100644 index 0000000000..e3c35481ef --- /dev/null +++ b/demo/collections/CustomComponents/components/fields/Select/Field/index.tsx @@ -0,0 +1,55 @@ +import React, { useCallback, useState } from 'react'; +import SelectInput from '../../../../../../../src/admin/components/forms/field-types/Select'; +import { Props as SelectFieldType } from '../../../../../../../src/admin/components/forms/field-types/Select/types'; +import useField from '../../../../../../../src/admin/components/forms/useField'; + +const Select: React.FC = (props) => { + const { + path, + name, + label, + options + } = props; + + const { + value, + setValue + } = useField({ + path + }); + + const onChange = useCallback((incomingValue) => { + const sendToCRM = async () => { + try { + const req = await fetch('https://fake-crm.com', { + method: 'post', + body: JSON.stringify({ + someKey: incomingValue + }) + }); + + const res = await req.json(); + if (res.ok) { + console.log('Successfully synced to CRM.') + } + } catch (e) { + console.error(e); + } + } + + sendToCRM(); + setValue(incomingValue) + }, []) + + return ( + + ) +}; + +export default Select; diff --git a/demo/collections/CustomComponents/components/fields/Text/Field/index.tsx b/demo/collections/CustomComponents/components/fields/Text/Field/index.tsx new file mode 100644 index 0000000000..580486d2cf --- /dev/null +++ b/demo/collections/CustomComponents/components/fields/Text/Field/index.tsx @@ -0,0 +1,35 @@ +import React, { useCallback, useState } from 'react'; +import TextInput from '../../../../../../../src/admin/components/forms/field-types/Text'; +import { Props as TextFieldType } from '../../../../../../../src/admin/components/forms/field-types/Text/types'; +import useField from '../../../../../../../src/admin/components/forms/useField'; + +const Text: React.FC = (props) => { + const { + path, + name, + label + } = props; + + const { + value, + setValue + } = useField({ + path + }); + + const onChange = useCallback((incomingValue) => { + const valueWithoutSpaces = incomingValue.replace(/\s/g, ''); + setValue(valueWithoutSpaces) + }, []) + + return ( + + ) +}; + +export default Text; diff --git a/demo/collections/CustomComponents/components/fields/UI/Field/index.tsx b/demo/collections/CustomComponents/components/fields/UI/Field/index.tsx new file mode 100644 index 0000000000..dd56f4d6f7 --- /dev/null +++ b/demo/collections/CustomComponents/components/fields/UI/Field/index.tsx @@ -0,0 +1,50 @@ +import React, { useCallback } from 'react'; +import TextInput from '../../../../../../../src/admin/components/forms/field-types/Text'; +import { UIField as UIFieldType } from '../../../../../../../src/fields/config/types'; +import SelectInput from '../../../../../../../src/admin/components/forms/field-types/Select'; + +const UIField: React.FC = () => { + const [textValue, setTextValue] = React.useState(''); + const [selectValue, setSelectValue] = React.useState(''); + + const onTextChange = useCallback((incomingValue) => { + setTextValue(incomingValue); + }, []) + + const onSelectChange = useCallback((incomingValue) => { + setSelectValue(incomingValue); + }, []) + + return ( +
+ + +
+ ) +}; + +export default UIField; diff --git a/demo/collections/CustomComponents/components/fields/Upload/Field/index.tsx b/demo/collections/CustomComponents/components/fields/Upload/Field/index.tsx new file mode 100644 index 0000000000..dd5b0f4967 --- /dev/null +++ b/demo/collections/CustomComponents/components/fields/Upload/Field/index.tsx @@ -0,0 +1,38 @@ +import React, { useCallback, useState } from 'react'; +import Upload from '../../../../../../../src/admin/components/forms/field-types/Upload'; +import { Props as UploadFieldType } from '../../../../../../../src/admin/components/forms/field-types/Upload/types'; +import useField from '../../../../../../../src/admin/components/forms/useField'; + +const Text: React.FC = (props) => { + const { + path, + name, + label, + relationTo, + fieldTypes + } = props; + + const { + value, + setValue + } = useField({ + path + }); + + const onChange = useCallback((incomingValue) => { + setValue(incomingValue) + }, []) + + return ( + + ) +}; + +export default Text; diff --git a/demo/collections/CustomComponents/index.ts b/demo/collections/CustomComponents/index.ts index 2320a8bc1a..e09ef5ae1f 100644 --- a/demo/collections/CustomComponents/index.ts +++ b/demo/collections/CustomComponents/index.ts @@ -1,11 +1,15 @@ import { CollectionConfig } from '../../../src/collections/config/types'; import DescriptionField from './components/fields/Description/Field'; +import TextField from './components/fields/Text/Field'; +import SelectField from './components/fields/Select/Field'; +import UploadField from './components/fields/Upload/Field'; import DescriptionCell from './components/fields/Description/Cell'; import DescriptionFilter from './components/fields/Description/Filter'; import NestedArrayField from './components/fields/NestedArrayCustomField/Field'; import GroupField from './components/fields/Group/Field'; import NestedGroupField from './components/fields/NestedGroupCustomField/Field'; import NestedText1Field from './components/fields/NestedText1/Field'; +import UIField from './components/fields/UI/Field'; import ListView from './components/views/List'; import CustomDescriptionComponent from '../../customComponents/Description'; @@ -25,11 +29,69 @@ const CustomComponents: CollectionConfig = { unique: true, localized: true, }, + { + name: 'text', + label: 'Custom text field (removes whitespace)', + type: 'text', + required: true, + localized: true, + admin: { + components: { + Field: TextField, + }, + }, + }, + { + name: 'select', + label: 'Custom select field (sends value to crm)', + type: 'select', + localized: true, + options: [ + { + label: 'Option 1', + value: '1', + }, + { + label: 'Option 2', + value: '2', + }, + { + label: 'Option 3', + value: '3', + }, + ], + admin: { + components: { + Field: SelectField, + }, + }, + }, + { + name: 'ui', + label: 'UI', + type: 'ui', + admin: { + components: { + Field: UIField, + }, + }, + }, + { + name: 'upload', + label: 'Upload', + type: 'upload', + relationTo: 'media', + localized: true, + admin: { + components: { + Field: UploadField, + }, + }, + }, { name: 'description', label: 'Description', type: 'textarea', - required: true, localized: true, admin: { components: { diff --git a/demo/collections/Hooks.ts b/demo/collections/Hooks.ts index 89aa279872..371213e2e7 100644 --- a/demo/collections/Hooks.ts +++ b/demo/collections/Hooks.ts @@ -1,6 +1,8 @@ -/* eslint-disable no-param-reassign */ - -import { CollectionConfig } from '../../src/collections/config/types'; +/* eslint-disable no-param-reassign, no-console */ +// If importing outside of demo project, should import CollectionAfterReadHook, CollectionBeforeChangeHook, etc +import { AfterChangeHook, AfterDeleteHook, AfterReadHook, BeforeChangeHook, BeforeDeleteHook, BeforeReadHook, CollectionConfig } from '../../src/collections/config/types'; +import { FieldHook } from '../../src/fields/config/types'; +import { Hook } from '../payload-types'; const Hooks: CollectionConfig = { slug: 'hooks', @@ -19,51 +21,51 @@ const Hooks: CollectionConfig = { }, hooks: { beforeRead: [ - (operation) => { + ((operation) => { if (operation.req.headers.hook === 'beforeRead') { console.log('before reading Hooks document'); } - }, + }) as BeforeReadHook, ], beforeChange: [ - (operation) => { + ((operation) => { if (operation.req.headers.hook === 'beforeChange') { operation.data.description += '-beforeChangeSuffix'; } return operation.data; - }, + }) as BeforeChangeHook, ], beforeDelete: [ - (operation) => { + ((operation) => { if (operation.req.headers.hook === 'beforeDelete') { // TODO: Find a better hook operation to assert against in tests operation.req.headers.hook = 'afterDelete'; } - }, + }) as BeforeDeleteHook, ], afterRead: [ - (operation) => { + ((operation) => { const { doc } = operation; doc.afterReadHook = true; return doc; - }, + }) as AfterReadHook, ], afterChange: [ - (operation) => { + ((operation) => { if (operation.req.headers.hook === 'afterChange') { operation.doc.afterChangeHook = true; } return operation.doc; - }, + }) as AfterChangeHook, ], afterDelete: [ - (operation) => { + ((operation) => { if (operation.req.headers.hook === 'afterDelete') { operation.doc.afterDeleteHook = true; } return operation.doc; - }, + }) as AfterDeleteHook, ], }, fields: [ @@ -77,7 +79,7 @@ const Hooks: CollectionConfig = { localized: true, hooks: { afterRead: [ - ({ value }) => (value ? value.toUpperCase() : null), + ({ value }) => (value ? value.toUpperCase() : null) as FieldHook, ], }, }, diff --git a/payload-types.ts b/demo/payload-types.ts similarity index 95% rename from payload-types.ts rename to demo/payload-types.ts index 1f8b81bc6b..29751ee959 100644 --- a/payload-types.ts +++ b/demo/payload-types.ts @@ -1,4 +1,9 @@ -// auto-generated by payload +/* tslint:disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ export interface Config {} /** @@ -6,6 +11,7 @@ export interface Config {} * via the `definition` "navigation-array". */ export interface NavigationArray { + id: string; array?: { text?: string; textarea?: string; @@ -17,6 +23,7 @@ export interface NavigationArray { * via the `definition` "global-with-access". */ export interface GlobalWithStrictAccess { + id: string; title: string; relationship: (string | LocalizedPost)[]; singleRelationship: string | LocalizedPost; @@ -26,6 +33,7 @@ export interface GlobalWithStrictAccess { * via the `definition` "localized-posts". */ export interface LocalizedPost { + id: string; title: string; summary?: string; description: string; @@ -57,6 +65,7 @@ export interface LocalizedPost { * via the `definition` "blocks-global". */ export interface BlocksGlobal { + id: string; blocks?: ( | { author: string | PublicUser; @@ -80,6 +89,7 @@ export interface BlocksGlobal { * via the `definition` "public-users". */ export interface PublicUser { + id: string; email?: string; resetPasswordToken?: string; resetPasswordExpiration?: string; @@ -94,6 +104,7 @@ export interface PublicUser { * via the `definition` "admins". */ export interface Admin { + id: string; email?: string; resetPasswordToken?: string; resetPasswordExpiration?: string; @@ -110,6 +121,7 @@ export interface Admin { * via the `definition` "all-fields". */ export interface AllFields { + id: string; text: string; descriptionText?: string; descriptionFunction?: string; @@ -185,6 +197,7 @@ export interface AllFields { * via the `definition` "media". */ export interface Media { + id: string; url?: string; filename?: string; mimeType?: string; @@ -233,6 +246,7 @@ export interface Media { * via the `definition` "conditions". */ export interface Conditions { + id: string; title: string; enableTest?: boolean; number?: number; @@ -274,6 +288,7 @@ export interface Conditions { * via the `definition` "auto-label". */ export interface AutoLabel { + id: string; autoLabelField?: string; noLabel?: string; labelOverride?: string; @@ -304,6 +319,7 @@ export interface AutoLabel { * via the `definition` "code". */ export interface Code { + id: string; code: string; } /** @@ -311,6 +327,7 @@ export interface Code { * via the `definition` "custom-components". */ export interface CustomComponent { + id: string; title: string; description: string; componentDescription?: string; @@ -329,7 +346,7 @@ export interface CustomComponent { * via the `definition` "custom-id". */ export interface CustomID { - id?: number; + id: number; name: string; } /** @@ -337,6 +354,7 @@ export interface CustomID { * via the `definition` "files". */ export interface File { + id: string; url?: string; filename?: string; mimeType?: string; @@ -349,6 +367,7 @@ export interface File { * via the `definition` "default-values". */ export interface DefaultValueTest { + id: string; text?: string; image?: string | Media; select?: 'option-1' | 'option-2' | 'option-3' | 'option-4'; @@ -419,6 +438,7 @@ export interface DefaultValueTest { * via the `definition` "blocks". */ export interface Blocks { + id: string; layout: ( | { testEmail: string; @@ -483,6 +503,7 @@ export interface Blocks { * via the `definition` "hidden-fields". */ export interface HiddenFields { + id: string; title: string; hiddenAdmin: string; hiddenAPI: string; @@ -492,6 +513,7 @@ export interface HiddenFields { * via the `definition` "hooks". */ export interface Hook { + id: string; title: string; description: string; } @@ -500,6 +522,7 @@ export interface Hook { * via the `definition` "localized-arrays". */ export interface LocalizedArray { + id: string; array: { allowPublicReadability?: boolean; arrayText1: string; @@ -513,6 +536,7 @@ export interface LocalizedArray { * via the `definition` "local-operations". */ export interface LocalOperation { + id: string; title: string; } /** @@ -520,6 +544,7 @@ export interface LocalOperation { * via the `definition` "nested-arrays". */ export interface NestedArray { + id: string; array: { parentIdentifier: string; nestedArray: { @@ -538,6 +563,7 @@ export interface NestedArray { * via the `definition` "previewable-post". */ export interface PreviewablePost { + id: string; title: string; } /** @@ -545,6 +571,7 @@ export interface PreviewablePost { * via the `definition` "relationship-a". */ export interface RelationshipA { + id: string; post?: string | RelationshipB; LocalizedPost?: (string | LocalizedPost)[]; postLocalizedMultiple?: ( @@ -573,6 +600,7 @@ export interface RelationshipA { * via the `definition` "relationship-b". */ export interface RelationshipB { + id: string; title?: string; post?: (string | RelationshipA)[]; postManyRelationships?: @@ -601,6 +629,7 @@ export interface RelationshipB { * via the `definition` "strict-access". */ export interface StrictAccess { + id: string; address: string; city: string; state: string; @@ -611,6 +640,7 @@ export interface StrictAccess { * via the `definition` "rich-text". */ export interface RichText { + id: string; defaultRichText: { [k: string]: unknown; }[]; @@ -623,6 +653,7 @@ export interface RichText { * via the `definition` "select". */ export interface Select { + id: string; Select: 'one' | 'two' | 'three'; SelectHasMany: ('one' | 'two' | 'three')[]; SelectJustStrings: ('blue' | 'green' | 'yellow')[]; @@ -633,6 +664,7 @@ export interface Select { * via the `definition` "validations". */ export interface Validation { + id: string; text: string; lessThan10: number; greaterThan10LessThan50: number; @@ -650,6 +682,7 @@ export interface Validation { * via the `definition` "uniques". */ export interface Unique { + id: string; title: string; description?: string; } @@ -658,6 +691,7 @@ export interface Unique { * via the `definition` "unstored-media". */ export interface UnstoredMedia { + id: string; url?: string; filename?: string; mimeType?: string; @@ -681,6 +715,7 @@ export interface UnstoredMedia { * via the `definition` "geolocation". */ export interface Geolocation { + id: string; location?: [number, number]; localizedPoint?: [number, number]; } diff --git a/demo/payload.config.ts b/demo/payload.config.ts index ef9540c334..102eb22e7f 100644 --- a/demo/payload.config.ts +++ b/demo/payload.config.ts @@ -38,7 +38,7 @@ export default buildConfig({ cookiePrefix: 'payload', serverURL: 'http://localhost:3000', typescript: { - outputFile: path.resolve(__dirname, './generated-types.ts'), + outputFile: path.resolve(__dirname, './payload-types.ts'), }, admin: { user: 'admins', diff --git a/docs/admin/components.mdx b/docs/admin/components.mdx index 95aadf323e..9c24fb07f4 100644 --- a/docs/admin/components.mdx +++ b/docs/admin/components.mdx @@ -90,13 +90,13 @@ All Payload fields support the ability to swap in your own React components. So, #### Sending and receiving values from the form -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 `useFieldType` hook as follows: +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 { useFieldType } from 'payload/components/forms'; +import { useField } from 'payload/components/forms'; const CustomTextField = ({ path }) => { - const { value, setValue } = useFieldType({ path }); + const { value, setValue } = useField({ path }); return ( Important
Due to GraphQL's typed nature, you should never change the type of data that you return from a field, otherwise GraphQL will produce errors. If you need to change the shape or type of data, reconsider Field Hooks and instead evaluate if Collection / Global hooks might suit you better. + +## TypeScript + +Payload exports a type for field hooks which can be accessed and used as follows: + +```js +import type { FieldHook } from 'payload/types'; + +// Field hook type is a generic that takes two arguments: +// 1: The document type +// 2: the value type + +type ExampleFieldHook = FieldHook; + +const exampleFieldHook: ExampleFieldHook = (args) => { + const { + value, // Typed as `string` as shown above + data, // Typed as a Partial of your ExampleDocumentType + originalDoc, // Typed as ExampleDocumentType + operation, + req, + } + + // Do something here... + + return value; // should return a string as typed above, undefined, or null +} +``` diff --git a/docs/hooks/globals.mdx b/docs/hooks/globals.mdx index cdfb919b7c..6b2da6e67d 100644 --- a/docs/hooks/globals.mdx +++ b/docs/hooks/globals.mdx @@ -98,3 +98,20 @@ const afterReadHook = async ({ req, // full express request }) => {...} ``` + +## TypeScript + +Payload exports a type for each Global hook which can be accessed as follows: + +```js +import type { + GlobalBeforeValidateHook, + GlobalBeforeChangeHook, + GlobalAfterChangeHook, + GlobalBeforeReadHook, + GlobalAfterReadHook, +} from 'payload/types'; + +// Use hook types here... +} +``` diff --git a/docs/typescript/generating-types.mdx b/docs/typescript/generating-types.mdx new file mode 100644 index 0000000000..3240360b79 --- /dev/null +++ b/docs/typescript/generating-types.mdx @@ -0,0 +1,114 @@ +--- +title: Generating TypeScript Interfaces +label: Generating Types +order: 20 +desc: Generate your own TypeScript interfaces based on your collections and globals. +keywords: headless cms, typescript, documentation, Content Management System, cms, headless, javascript, node, react, express +--- + +While building your own custom functionality into Payload, like plugins, hooks, access control functions, custom routes, GraphQL queries / mutations, or anything else, you may benefit from generating your own TypeScript types dynamically from your Payload config itself. + +Run the following command in a Payload project to generate types: + +``` +payload generate:types +``` + +You can run this command whenever you need to regenerate your types, and then you can use these types in your Payload code directly. + +For example, let's look at the following simple Payload config: + +```ts +const config: Config = { + serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL, + admin: { + user: 'users', + } + collections: [ + { + slug: 'users', + fields: [ + { + name: 'name', + type: 'text', + required: true, + } + ] + }, + { + slug: 'posts', + admin: { + useAsTitle: 'title', + }, + fields: [ + { + name: 'title', + type: 'text', + }, + { + name: 'author', + type: 'relationship', + relationTo: 'users', + }, + ] + } + ] +} +``` + +By generating types, we'll end up with a file containing the following two TypeScript interfaces: + +```ts +export interface User { + id: string; + name: string; + email?: string; + resetPasswordToken?: string; + resetPasswordExpiration?: string; + loginAttempts?: number; + lockUntil?: string; +} + +export interface Post { + id: string; + title?: string; + author?: string | User; +} + +``` + +#### Customizing the output path of your generated types + +You can specify where you want your types to be generated by adding a property to your Payload config: + +``` +{ + // the remainder of your config + typescript: { + outputFile: path.resolve(__dirname, './generated-types.ts'), + }, +} +``` + +The above example places your types next to your Payload config itself as the file `generated-types.ts`. By default, the file will be output to your current working directory as `payload-types.ts`. + +#### Adding an NPM script + + + Important:
+ Payload needs to be able to find your config to generate your types. +
+ +Payload will automatically try and locate your config, but might not always be able to find it. For example, if you are working in a `/src` directory or similar, you need to tell Payload where to find your config manually by using an environment variable. If this applies to you, you can create an NPM script to make generating your types easier. + +To add an NPM script to generate your types and show Payload where to find your config, open your `package.json` and update the `scripts` property to the following: + +``` +{ + "scripts": { + "generate:types": "PAYLOAD_CONFIG_PATH=src/payload.config.ts payload generate:types", + }, +} +``` + +Now you can run `yarn generate:types` to easily generate your types. diff --git a/docs/typescript/overview.mdx b/docs/typescript/overview.mdx new file mode 100644 index 0000000000..f9bfc7b78e --- /dev/null +++ b/docs/typescript/overview.mdx @@ -0,0 +1,36 @@ +--- +title: TypeScript - Overview +label: Overview +order: 10 +desc: Payload is the most powerful TypeScript headless CMS available. +keywords: headless cms, typescript, documentation, Content Management System, cms, headless, javascript, node, react, express +--- + +Payload supports TypeScript natively, and not only that, the entirety of the CMS is built with TypeScript. To get started developing with Payload and TypeScript, you can use one of Payload's built-in boilerplates in one line via `create-payload-app`: + +``` +npx create-payload-app +``` + +Pick a TypeScript project type to get started easily. + +#### Setting up from Scratch + +It's also possible to set up a TypeScript project from scratch. We plan to write up a guide for exactly how—so keep an eye out for that, too. + +## Using Payload's Exported Types + +Payload exports a number of types that you may find useful while writing your own plugins, hooks, access control functions, custom routes, GraphQL queries / mutations, or anything else. + +##### Config Types + +- [Base config](/docs/configuration/overview#typescript) +- [Collections](/docs/configuration/collections#typescript) +- [Globals](/docs/configuration/globals#typescript) +- [Fields](/docs/fields/overview#typescript) + +##### Hook Types + +- [Collection hooks](/docs/hooks/collections#typescript) +- [Global hooks](/docs/hooks/globals#typescript) +- [Field hooks](/docs/hooks/fields#typescript) diff --git a/package.json b/package.json index f1a76e752e..6d727ce7bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "payload", - "version": "0.12.13-beta.0", + "version": "0.13.0", "description": "Node, React and MongoDB Headless CMS and Application Framework", "license": "SEE LICENSE IN license.md", "author": { diff --git a/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx b/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx index 43d6df755e..4d4321c41c 100644 --- a/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx +++ b/src/admin/components/elements/WhereBuilder/Condition/Relationship/index.tsx @@ -63,7 +63,7 @@ const RelationshipField: React.FC = (props) => { const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageToUse}&depth=0${searchParam}`); if (response.ok) { - const data: PaginatedDocs = await response.json(); + const data: PaginatedDocs = await response.json(); if (data.docs.length > 0) { resultsFetched += data.docs.length; addOptions(data, relation); diff --git a/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts b/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts index 6a3a46e069..0071195bd9 100644 --- a/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts +++ b/src/admin/components/elements/WhereBuilder/Condition/Relationship/types.ts @@ -20,7 +20,7 @@ type CLEAR = { type ADD = { type: 'ADD' - data: PaginatedDocs + data: PaginatedDocs relation: string hasMultipleRelations: boolean collection: SanitizedCollectionConfig diff --git a/src/admin/components/forms/DraggableSection/SectionTitle/index.tsx b/src/admin/components/forms/DraggableSection/SectionTitle/index.tsx index 7145825598..f1fe7ca3ce 100644 --- a/src/admin/components/forms/DraggableSection/SectionTitle/index.tsx +++ b/src/admin/components/forms/DraggableSection/SectionTitle/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import Pill from '../../../elements/Pill'; import { Props } from './types'; @@ -10,7 +10,7 @@ const baseClass = 'section-title'; const SectionTitle: React.FC = (props) => { const { label, path, readOnly } = props; - const { value, setValue } = useFieldType({ path }); + const { value, setValue } = useField({ path }); const classes = [ baseClass, diff --git a/src/admin/components/forms/field-types/Array/Array.tsx b/src/admin/components/forms/field-types/Array/Array.tsx index a51001e277..be2d940073 100644 --- a/src/admin/components/forms/field-types/Array/Array.tsx +++ b/src/admin/components/forms/field-types/Array/Array.tsx @@ -7,7 +7,7 @@ import DraggableSection from '../../DraggableSection'; import reducer from '../rowReducer'; import { useForm } from '../../Form/context'; import buildStateFromSchema from '../../Form/buildStateFromSchema'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import Error from '../../Error'; import { array } from '../../../../../fields/validations'; import Banner from '../../../elements/Banner'; @@ -66,7 +66,7 @@ const ArrayFieldType: React.FC = (props) => { errorMessage, value, setValue, - } = useFieldType({ + } = useField({ path, validate: memoizedValidate, disableFormData, diff --git a/src/admin/components/forms/field-types/Blocks/Blocks.tsx b/src/admin/components/forms/field-types/Blocks/Blocks.tsx index 7cc49cd83a..d6a18f20ce 100644 --- a/src/admin/components/forms/field-types/Blocks/Blocks.tsx +++ b/src/admin/components/forms/field-types/Blocks/Blocks.tsx @@ -12,7 +12,7 @@ import { useForm } from '../../Form/context'; import buildStateFromSchema from '../../Form/buildStateFromSchema'; import DraggableSection from '../../DraggableSection'; import Error from '../../Error'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import Popup from '../../../elements/Popup'; import BlockSelector from './BlockSelector'; import { blocks as blocksValidator } from '../../../../../fields/validations'; @@ -76,7 +76,7 @@ const Blocks: React.FC = (props) => { errorMessage, value, setValue, - } = useFieldType({ + } = useField({ path, validate: memoizedValidate, disableFormData, @@ -110,7 +110,7 @@ const Blocks: React.FC = (props) => { if (preferencesKey) { const preferences: DocumentPreferences = await getPreference(preferencesKey); - const preferencesToSet = preferences || { fields: { } }; + const preferencesToSet = preferences || { fields: {} }; let newCollapsedState = preferencesToSet?.fields?.[path]?.collapsed .filter((filterID) => (rows.find((row) => row.id === filterID))) || []; diff --git a/src/admin/components/forms/field-types/Checkbox/index.tsx b/src/admin/components/forms/field-types/Checkbox/index.tsx index d49342318c..4bf44687b5 100644 --- a/src/admin/components/forms/field-types/Checkbox/index.tsx +++ b/src/admin/components/forms/field-types/Checkbox/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import withCondition from '../../withCondition'; import Error from '../../Error'; import { checkbox } from '../../../../../fields/validations'; @@ -41,7 +41,7 @@ const Checkbox: React.FC = (props) => { showError, errorMessage, setValue, - } = useFieldType({ + } = useField({ path, validate: memoizedValidate, disableFormData, diff --git a/src/admin/components/forms/field-types/Code/Code.tsx b/src/admin/components/forms/field-types/Code/Code.tsx index 5c4f4b2acd..bf6c0db10c 100644 --- a/src/admin/components/forms/field-types/Code/Code.tsx +++ b/src/admin/components/forms/field-types/Code/Code.tsx @@ -3,7 +3,7 @@ import Editor from 'react-simple-code-editor'; import { highlight, languages } from 'prismjs/components/prism-core'; import 'prismjs/components/prism-clike'; import 'prismjs/components/prism-javascript'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import withCondition from '../../withCondition'; import Label from '../../Label'; import Error from '../../Error'; @@ -52,7 +52,7 @@ const Code: React.FC = (props) => { showError, setValue, errorMessage, - } = useFieldType({ + } = useField({ path, validate: memoizedValidate, enableDebouncedValue: true, diff --git a/src/admin/components/forms/field-types/ConfirmPassword/index.tsx b/src/admin/components/forms/field-types/ConfirmPassword/index.tsx index ede7f5b335..3428f2fb2c 100644 --- a/src/admin/components/forms/field-types/ConfirmPassword/index.tsx +++ b/src/admin/components/forms/field-types/ConfirmPassword/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import Label from '../../Label'; import Error from '../../Error'; import { useWatchForm } from '../../Form/context'; @@ -23,7 +23,7 @@ const ConfirmPassword: React.FC = () => { showError, setValue, errorMessage, - } = useFieldType({ + } = useField({ path: 'confirm-password', disableFormData: true, validate, diff --git a/src/admin/components/forms/field-types/DateTime/index.tsx b/src/admin/components/forms/field-types/DateTime/index.tsx index cebb44ebdd..1e17e22bb2 100644 --- a/src/admin/components/forms/field-types/DateTime/index.tsx +++ b/src/admin/components/forms/field-types/DateTime/index.tsx @@ -2,7 +2,7 @@ import React, { useCallback } from 'react'; import DatePicker from '../../../elements/DatePicker'; import withCondition from '../../withCondition'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import Label from '../../Label'; import Error from '../../Error'; import FieldDescription from '../../FieldDescription'; @@ -43,7 +43,7 @@ const DateTime: React.FC = (props) => { showError, errorMessage, setValue, - } = useFieldType({ + } = useField({ path, validate: memoizedValidate, condition, diff --git a/src/admin/components/forms/field-types/Email/index.tsx b/src/admin/components/forms/field-types/Email/index.tsx index e857da0980..91979c3b6e 100644 --- a/src/admin/components/forms/field-types/Email/index.tsx +++ b/src/admin/components/forms/field-types/Email/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; import withCondition from '../../withCondition'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import Label from '../../Label'; import Error from '../../Error'; import FieldDescription from '../../FieldDescription'; @@ -34,7 +34,7 @@ const Email: React.FC = (props) => { return validationResult; }, [validate, required]); - const fieldType = useFieldType({ + const fieldType = useField({ path, validate: memoizedValidate, enableDebouncedValue: true, diff --git a/src/admin/components/forms/field-types/HiddenInput/index.tsx b/src/admin/components/forms/field-types/HiddenInput/index.tsx index 2ae0af77ef..318cd7a250 100644 --- a/src/admin/components/forms/field-types/HiddenInput/index.tsx +++ b/src/admin/components/forms/field-types/HiddenInput/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import withCondition from '../../withCondition'; import { Props } from './types'; @@ -13,7 +13,7 @@ const HiddenInput: React.FC = (props) => { const path = pathFromProps || name; - const { value, setValue } = useFieldType({ + const { value, setValue } = useField({ path, }); diff --git a/src/admin/components/forms/field-types/Number/index.tsx b/src/admin/components/forms/field-types/Number/index.tsx index f0af81f922..cb8b4c01ca 100644 --- a/src/admin/components/forms/field-types/Number/index.tsx +++ b/src/admin/components/forms/field-types/Number/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import Label from '../../Label'; import Error from '../../Error'; import FieldDescription from '../../FieldDescription'; @@ -41,7 +41,7 @@ const NumberField: React.FC = (props) => { showError, setValue, errorMessage, - } = useFieldType({ + } = useField({ path, validate: memoizedValidate, enableDebouncedValue: true, diff --git a/src/admin/components/forms/field-types/Password/index.tsx b/src/admin/components/forms/field-types/Password/index.tsx index e26a585d86..5bafb357d4 100644 --- a/src/admin/components/forms/field-types/Password/index.tsx +++ b/src/admin/components/forms/field-types/Password/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import Label from '../../Label'; import Error from '../../Error'; import withCondition from '../../withCondition'; @@ -33,7 +33,7 @@ const Password: React.FC = (props) => { formProcessing, setValue, errorMessage, - } = useFieldType({ + } = useField({ path, validate: memoizedValidate, enableDebouncedValue: true, diff --git a/src/admin/components/forms/field-types/Point/index.tsx b/src/admin/components/forms/field-types/Point/index.tsx index 62f0cc1759..2bdfd89406 100644 --- a/src/admin/components/forms/field-types/Point/index.tsx +++ b/src/admin/components/forms/field-types/Point/index.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import Label from '../../Label'; import Error from '../../Error'; import FieldDescription from '../../FieldDescription'; @@ -41,7 +41,7 @@ const PointField: React.FC = (props) => { showError, setValue, errorMessage, - } = useFieldType<[number, number]>({ + } = useField<[number, number]>({ path, validate: memoizedValidate, enableDebouncedValue: true, diff --git a/src/admin/components/forms/field-types/RadioGroup/index.tsx b/src/admin/components/forms/field-types/RadioGroup/index.tsx index e6aa893cd1..4e84db9d5d 100644 --- a/src/admin/components/forms/field-types/RadioGroup/index.tsx +++ b/src/admin/components/forms/field-types/RadioGroup/index.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import withCondition from '../../withCondition'; import Error from '../../Error'; import Label from '../../Label'; @@ -44,7 +44,7 @@ const RadioGroup: React.FC = (props) => { showError, errorMessage, setValue, - } = useFieldType({ + } = useField({ path, validate: memoizedValidate, condition, diff --git a/src/admin/components/forms/field-types/Relationship/index.tsx b/src/admin/components/forms/field-types/Relationship/index.tsx index fcedde1c73..17ea530efc 100644 --- a/src/admin/components/forms/field-types/Relationship/index.tsx +++ b/src/admin/components/forms/field-types/Relationship/index.tsx @@ -5,7 +5,7 @@ import { useConfig } from '@payloadcms/config-provider'; import withCondition from '../../withCondition'; import ReactSelect from '../../../elements/ReactSelect'; import { Value } from '../../../elements/ReactSelect/types'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import Label from '../../Label'; import Error from '../../Error'; import FieldDescription from '../../FieldDescription'; @@ -70,7 +70,7 @@ const Relationship: React.FC = (props) => { showError, errorMessage, setValue, - } = useFieldType({ + } = useField({ path: path || name, validate: memoizedValidate, condition, @@ -106,7 +106,7 @@ const Relationship: React.FC = (props) => { const response = await fetch(`${serverURL}${api}/${relation}?limit=${maxResultsPerRequest}&page=${lastLoadedPageToUse}&depth=0${searchParam}`); if (response.ok) { - const data: PaginatedDocs = await response.json(); + const data: PaginatedDocs = await response.json(); if (data.docs.length > 0) { resultsFetched += data.docs.length; addOptions(data, relation); diff --git a/src/admin/components/forms/field-types/Relationship/types.ts b/src/admin/components/forms/field-types/Relationship/types.ts index 41daed293e..44c12dfa78 100644 --- a/src/admin/components/forms/field-types/Relationship/types.ts +++ b/src/admin/components/forms/field-types/Relationship/types.ts @@ -19,7 +19,7 @@ type CLEAR = { type ADD = { type: 'ADD' - data: PaginatedDocs + data: PaginatedDocs relation: string hasMultipleRelations: boolean collection: SanitizedCollectionConfig diff --git a/src/admin/components/forms/field-types/RichText/RichText.tsx b/src/admin/components/forms/field-types/RichText/RichText.tsx index de1a8b8989..0d01ad5001 100644 --- a/src/admin/components/forms/field-types/RichText/RichText.tsx +++ b/src/admin/components/forms/field-types/RichText/RichText.tsx @@ -4,7 +4,7 @@ import { createEditor, Transforms, Node, Element as SlateElement, Text, BaseEdit import { ReactEditor, Editable, withReact, Slate } from 'slate-react'; import { HistoryEditor, withHistory } from 'slate-history'; import { richText } from '../../../../../fields/validations'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import withCondition from '../../withCondition'; import Label from '../../Label'; import Error from '../../Error'; @@ -29,7 +29,7 @@ const defaultElements: RichTextElement[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline', 'strikethrough', 'code']; const baseClass = 'rich-text'; -type CustomText = { text: string; [x: string]: unknown } +type CustomText = { text: string;[x: string]: unknown } type CustomElement = { type: string; children: CustomText[] } @@ -115,7 +115,7 @@ const RichText: React.FC = (props) => { return validationResult; }, [validate, required]); - const fieldType = useFieldType({ + const fieldType = useField({ path, validate: memoizedValidate, stringify: true, @@ -194,7 +194,7 @@ const RichText: React.FC = (props) => { }} >
- { !hideGutter && () } + {!hideGutter && ()} = (props) => { description, condition, } = {}, + value: valueFromProps, + onChange: onChangeFromProps } = props; const path = pathFromProps || name; @@ -52,16 +54,42 @@ const Select: React.FC = (props) => { }, [validate, required, options]); const { - value, + value: valueFromContext, showError, setValue, errorMessage, - } = useFieldType({ + } = useField({ path, validate: memoizedValidate, condition, }); + const onChange = useCallback((selectedOption) => { + if (!readOnly) { + let newValue; + if (hasMany) { + if (Array.isArray(selectedOption)) { + newValue = selectedOption.map((option) => option.value); + } else { + newValue = []; + } + } else { + newValue = selectedOption.value; + } + + if (typeof onChangeFromProps === 'function') { + onChangeFromProps(newValue); + } else { + setValue(newValue); + } + } + }, [ + readOnly, + hasMany, + onChangeFromProps, + setValue + ]) + const classes = [ 'field-type', baseClass, @@ -71,6 +99,8 @@ const Select: React.FC = (props) => { let valueToRender; + const value = valueFromProps || valueFromContext || ''; + if (hasMany && Array.isArray(value)) { valueToRender = value.map((val) => options.find((option) => option.value === val)); } else { @@ -95,17 +125,7 @@ const Select: React.FC = (props) => { required={required} /> { - if (hasMany) { - if (Array.isArray(selectedOption)) { - setValue(selectedOption.map((option) => option.value)); - } else { - setValue([]); - } - } else { - setValue(selectedOption.value); - } - } : undefined} + onChange={onChange} value={valueToRender} showError={showError} isDisabled={readOnly} diff --git a/src/admin/components/forms/field-types/Text/index.tsx b/src/admin/components/forms/field-types/Text/index.tsx index 1854ca7d9b..aa6f044203 100644 --- a/src/admin/components/forms/field-types/Text/index.tsx +++ b/src/admin/components/forms/field-types/Text/index.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import useFieldType from '../../useFieldType'; +import React, { useCallback, useEffect } from 'react'; +import useField from '../../useField'; import withCondition from '../../withCondition'; import Label from '../../Label'; import Error from '../../Error'; @@ -24,11 +24,13 @@ const Text: React.FC = (props) => { description, condition, } = {}, + value: valueFromProps, + onChange: onChangeFromProps, } = props; const path = pathFromProps || name; - const fieldType = useFieldType({ + const fieldType = useField({ path, validate, enableDebouncedValue: true, @@ -36,12 +38,24 @@ const Text: React.FC = (props) => { }); const { - value, + value: valueFromContext, showError, setValue, errorMessage, } = fieldType; + const onChange = useCallback((e) => { + const { value: incomingValue } = e.target; + if (typeof onChangeFromProps === 'function') { + onChangeFromProps(incomingValue); + } else { + setValue(e); + } + }, [ + onChangeFromProps, + setValue, + ]); + const classes = [ 'field-type', 'text', @@ -49,6 +63,8 @@ const Text: React.FC = (props) => { readOnly && 'read-only', ].filter(Boolean).join(' '); + const value = valueFromProps || valueFromContext || ''; + return (
= (props) => { required={required} /> = (props) => { showError, setValue, errorMessage, - } = useFieldType({ + } = useField({ path, validate: memoizedValidate, enableDebouncedValue: true, diff --git a/src/admin/components/forms/field-types/Upload/index.tsx b/src/admin/components/forms/field-types/Upload/index.tsx index d687ef5551..bb76e35c13 100644 --- a/src/admin/components/forms/field-types/Upload/index.tsx +++ b/src/admin/components/forms/field-types/Upload/index.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useModal } from '@faceless-ui/modal'; import { useConfig } from '@payloadcms/config-provider'; -import useFieldType from '../../useFieldType'; +import useField from '../../useField'; import withCondition from '../../withCondition'; import Button from '../../../elements/Button'; import Label from '../../Label'; @@ -38,6 +38,8 @@ const Upload: React.FC = (props) => { validate = upload, relationTo, fieldTypes, + value: valueFromProps, + onChange: onChangeFromProps, } = props; const collection = collections.find((coll) => coll.slug === relationTo); @@ -51,19 +53,21 @@ const Upload: React.FC = (props) => { return validationResult; }, [validate, required]); - const fieldType = useFieldType({ + const fieldType = useField({ path, validate: memoizedValidate, condition, }); const { - value, + value: valueFromContext, showError, setValue, errorMessage, } = fieldType; + const value = valueFromProps || valueFromContext || ''; + const classes = [ 'field-type', baseClass, @@ -81,14 +85,28 @@ const Upload: React.FC = (props) => { setInternalValue(json); } else { setInternalValue(undefined); - setValue(null); setMissingFile(true); } }; fetchFile(); } - }, [value, setInternalValue, relationTo, api, serverURL, setValue]); + }, [ + value, + relationTo, + api, + serverURL, + setValue + ]); + + useEffect(() => { + const { id: incomingID } = internalValue || {}; + if (typeof onChangeFromProps === 'function') { + onChangeFromProps(incomingID) + } else { + setValue(incomingID); + } + }, [internalValue]); return (
= (props) => { fieldTypes, setValue: (val) => { setMissingFile(false); - setValue(val.id); setInternalValue(val); }, }} @@ -157,7 +174,6 @@ const Upload: React.FC = (props) => { slug: selectExistingModalSlug, setValue: (val) => { setMissingFile(false); - setValue(val.id); setInternalValue(val); }, addModalSlug, diff --git a/src/admin/components/forms/useFieldType/index.tsx b/src/admin/components/forms/useField/index.tsx similarity index 80% rename from src/admin/components/forms/useFieldType/index.tsx rename to src/admin/components/forms/useField/index.tsx index 9de075b9f3..5cee2de4a2 100644 --- a/src/admin/components/forms/useFieldType/index.tsx +++ b/src/admin/components/forms/useField/index.tsx @@ -5,7 +5,7 @@ import { useFormProcessing, useFormSubmitted, useFormModified, useForm } from '. import useDebounce from '../../../hooks/useDebounce'; import { Options, FieldType } from './types'; -const useFieldType = (options: Options): FieldType => { +const useField = (options: Options): FieldType => { const { path, validate, @@ -22,8 +22,10 @@ const useFieldType = (options: Options): FieldType => { const modified = useFormModified(); const { - dispatchFields, getField, setModified, - } = formContext; + dispatchFields, + getField, + setModified, + } = formContext || {}; const [internalValue, setInternalValue] = useState(undefined); @@ -64,21 +66,38 @@ const useFieldType = (options: Options): FieldType => { fieldToDispatch.valid = validationResult; } - dispatchFields(fieldToDispatch); - }, [path, dispatchFields, validate, disableFormData, ignoreWhileFlattening, initialValue, stringify, condition]); + if (typeof dispatchFields === 'function') { + dispatchFields(fieldToDispatch); + } + }, [ + path, + dispatchFields, + validate, + disableFormData, + ignoreWhileFlattening, + initialValue, + stringify, + condition + ]); - // Method to return from `useFieldType`, used to + // Method to return from `useField`, used to // update internal field values from field component(s) // as fast as they arrive. NOTE - this method is NOT debounced const setValue = useCallback((e, modifyForm = true) => { const val = (e && e.target) ? e.target.value : e; if ((!ignoreWhileFlattening && !modified) && modifyForm) { - setModified(true); + if (typeof setModified === 'function') { + setModified(true); + } } setInternalValue(val); - }, [setModified, modified, ignoreWhileFlattening]); + }, [ + setModified, + modified, + ignoreWhileFlattening + ]); useEffect(() => { setInternalValue(initialValue); @@ -94,7 +113,11 @@ const useFieldType = (options: Options): FieldType => { if (field?.value !== valueToSend && valueToSend !== undefined) { sendField(valueToSend); } - }, [valueToSend, sendField, field]); + }, [ + valueToSend, + sendField, + field + ]); return { ...options, @@ -107,4 +130,4 @@ const useFieldType = (options: Options): FieldType => { }; }; -export default useFieldType; +export default useField; diff --git a/src/admin/components/forms/useFieldType/types.ts b/src/admin/components/forms/useField/types.ts similarity index 100% rename from src/admin/components/forms/useFieldType/types.ts rename to src/admin/components/forms/useField/types.ts diff --git a/src/admin/components/views/collections/Edit/Auth/APIKey.tsx b/src/admin/components/views/collections/Edit/Auth/APIKey.tsx index bcbfad2bca..9f2bb6cbf2 100644 --- a/src/admin/components/views/collections/Edit/Auth/APIKey.tsx +++ b/src/admin/components/views/collections/Edit/Auth/APIKey.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState, useEffect } from 'react'; import { v4 as uuidv4 } from 'uuid'; -import useFieldType from '../../../../forms/useFieldType'; +import useField from '../../../../forms/useField'; import Label from '../../../../forms/Label'; import CopyToClipboard from '../../../../elements/CopyToClipboard'; import { text } from '../../../../../../fields/validations'; @@ -31,7 +31,7 @@ const APIKey: React.FC = () => {
), [apiKeyValue]); - const fieldType = useFieldType({ + const fieldType = useField({ path: 'apiKey', validate, }); diff --git a/src/admin/components/views/collections/Edit/Upload/index.tsx b/src/admin/components/views/collections/Edit/Upload/index.tsx index ba28fc50c2..ca8f5ddf44 100644 --- a/src/admin/components/views/collections/Edit/Upload/index.tsx +++ b/src/admin/components/views/collections/Edit/Upload/index.tsx @@ -1,7 +1,7 @@ import React, { useState, useRef, useEffect, useCallback, } from 'react'; -import useFieldType from '../../../../forms/useFieldType'; +import useField from '../../../../forms/useField'; import Button from '../../../../elements/Button'; import FileDetails from '../../../../elements/FileDetails'; import Error from '../../../../forms/Error'; @@ -45,7 +45,7 @@ const Upload: React.FC = (props) => { setValue, showError, errorMessage, - } = useFieldType<{name: string}>({ + } = useField<{ name: string }>({ path: 'file', validate, }); diff --git a/src/admin/components/views/collections/List/types.ts b/src/admin/components/views/collections/List/types.ts index a58855652e..946e6b89ab 100644 --- a/src/admin/components/views/collections/List/types.ts +++ b/src/admin/components/views/collections/List/types.ts @@ -3,7 +3,7 @@ import { Column } from '../../../elements/Table/types'; export type Props = { collection: SanitizedCollectionConfig - data: PaginatedDocs + data: PaginatedDocs newDocumentURL: string setListControls: (controls: unknown) => void setSort: (sort: string) => void diff --git a/src/bin/generateTypes.ts b/src/bin/generateTypes.ts index 32efbb4c1e..5d3e929575 100644 --- a/src/bin/generateTypes.ts +++ b/src/bin/generateTypes.ts @@ -363,6 +363,10 @@ export function generateTypes(): void { compile(jsonSchema, 'Config', { unreachableDefinitions: true, + bannerComment: '/* tslint:disable */\n/**\n* This file was automatically generated by Payload CMS.\n* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,\n* and re-run `payload generate:types` to regenerate this file.\n*/', + style: { + singleQuote: true, + }, }).then((compiled) => { fs.writeFileSync(config.typescript.outputFile, compiled); payload.logger.info(`Types written to ${config.typescript.outputFile}`); diff --git a/src/collections/config/types.ts b/src/collections/config/types.ts index f0c5123af5..5575b0665a 100644 --- a/src/collections/config/types.ts +++ b/src/collections/config/types.ts @@ -18,47 +18,71 @@ export interface AuthCollectionModel extends CollectionModel { } export type HookOperationType = - | 'create' - | 'read' - | 'update' - | 'delete' - | 'refresh' - | 'login' - | 'forgotPassword'; +| 'create' +| 'read' +| 'update' +| 'delete' +| 'refresh' +| 'login' +| 'forgotPassword'; + +type CreateOrUpdateOperation = Extract; export type BeforeOperationHook = (args: { args?: any; + /** + * Hook operation being performed + */ operation: HookOperationType; }) => any; -export type BeforeValidateHook = (args: { - data?: any; +export type BeforeValidateHook = (args: { + data?: Partial; req?: PayloadRequest; - operation: 'create' | 'update'; - originalDoc?: any; // undefined on 'create' operation + /** + * Hook operation being performed + */ + operation: CreateOrUpdateOperation; + /** + * Original document before change + * + * `undefined` on 'create' operation + */ + originalDoc?: T; }) => any; -export type BeforeChangeHook = (args: { - data: any; +export type BeforeChangeHook = (args: { + data: Partial; req: PayloadRequest; - operation: 'create' | 'update' - originalDoc?: any; // undefined on 'create' operation + /** + * Hook operation being performed + */ + operation: CreateOrUpdateOperation; + /** + * Original document before change + * + * `undefined` on 'create' operation + */ + originalDoc?: T; }) => any; -export type AfterChangeHook = (args: { - doc: any; +export type AfterChangeHook = (args: { + doc: T; req: PayloadRequest; - operation: 'create' | 'update'; + /** + * Hook operation being performed + */ + operation: CreateOrUpdateOperation; }) => any; -export type BeforeReadHook = (args: { - doc: any; +export type BeforeReadHook = (args: { + doc: T; req: PayloadRequest; query: { [key: string]: any }; }) => any; -export type AfterReadHook = (args: { - doc: any; +export type AfterReadHook = (args: { + doc: T; req: PayloadRequest; query?: { [key: string]: any }; }) => any; @@ -68,10 +92,10 @@ export type BeforeDeleteHook = (args: { id: string; }) => any; -export type AfterDeleteHook = (args: { +export type AfterDeleteHook = (args: { + doc: T; req: PayloadRequest; id: string; - doc: any; }) => any; export type AfterErrorHook = (err: Error, res: unknown) => { response: any, status: number } | void; @@ -80,9 +104,9 @@ export type BeforeLoginHook = (args: { req: PayloadRequest; }) => any; -export type AfterLoginHook = (args: { +export type AfterLoginHook = (args: { req: PayloadRequest; - doc: any; + doc: T; token: string; }) => any; @@ -90,31 +114,57 @@ export type AfterForgotPasswordHook = (args: { args?: any; }) => any; +export type CollectionAdminOptions = { + /** + * Field to use as title in Edit view and first column in List view + */ + useAsTitle?: string; + /** + * Default columns to show in list view + */ + defaultColumns?: string[]; + /** + * Custom description for collection + */ + description?: string | (() => string) | React.FC; + disableDuplicate?: boolean; + /** + * Custom admin components + */ + components?: { + views?: { + Edit?: React.ComponentType + List?: React.ComponentType + } + }; + pagination?: { + defaultLimit?: number + limits?: number[] + } + enableRichTextRelationship?: boolean + /** + * Function to generate custom preview URL + */ + preview?: GeneratePreviewURL +} + export type CollectionConfig = { slug: string; + /** + * Label configuration + */ labels?: { singular?: string; plural?: string; }; fields: Field[]; - admin?: { - useAsTitle?: string; - defaultColumns?: string[]; - description?: string | (() => string); - disableDuplicate?: boolean; - components?: { - views?: { - Edit?: React.ComponentType - List?: React.ComponentType - } - }; - pagination?: { - defaultLimit?: number - limits?: number[] - } - enableRichTextRelationship?: boolean - preview?: GeneratePreviewURL - }; + /** + * Collection admin options + */ + admin?: CollectionAdminOptions; + /** + * Hooks to modify Payload functionality + */ hooks?: { beforeOperation?: BeforeOperationHook[]; beforeValidate?: BeforeValidateHook[]; @@ -129,6 +179,9 @@ export type CollectionConfig = { afterLogin?: AfterLoginHook[]; afterForgotPassword?: AfterForgotPasswordHook[]; }; + /** + * Access control + */ access?: { create?: Access; read?: Access; @@ -138,7 +191,15 @@ export type CollectionConfig = { unlock?: Access; readRevisions?: Access; }; + /** + * Collection login options + * + * Use `true` to enable with default options + */ auth?: IncomingAuthType | boolean; + /** + * Upload options + */ upload?: IncomingUploadType | boolean; revisions?: IncomingRevisionsType | boolean; timestamps?: boolean @@ -160,8 +221,8 @@ export type AuthCollection = { config: SanitizedCollectionConfig; } -export type PaginatedDocs = { - docs: any[] +export type PaginatedDocs = { + docs: T[] totalDocs: number limit: number totalPages: number @@ -172,3 +233,7 @@ export type PaginatedDocs = { prevPage: number | null nextPage: number | null } + +export type TypeWithID = { + id: string | number +} diff --git a/src/collections/operations/create.ts b/src/collections/operations/create.ts index dde5936672..8b82e8fd55 100644 --- a/src/collections/operations/create.ts +++ b/src/collections/operations/create.ts @@ -29,6 +29,7 @@ export type Arguments = { overrideAccess?: boolean showHiddenFields?: boolean data: Record + overwriteExistingFiles?: boolean } async function create(this: Payload, incomingArgs: Arguments): Promise { @@ -59,6 +60,7 @@ async function create(this: Payload, incomingArgs: Arguments): Promise depth, overrideAccess, showHiddenFields, + overwriteExistingFiles = false, } = args; let { data } = args; @@ -108,7 +110,7 @@ async function create(this: Payload, incomingArgs: Arguments): Promise mkdirp.sync(staticPath); } - const fsSafeName = await getSafeFilename(staticPath, file.name); + const fsSafeName = !overwriteExistingFiles ? await getSafeFilename(Model, staticPath, file.name) : file.name; try { if (!disableLocalStorage) { @@ -122,7 +124,15 @@ async function create(this: Payload, incomingArgs: Arguments): Promise if (Array.isArray(imageSizes) && file.mimetype !== 'image/svg+xml') { req.payloadUploadSizes = {}; - fileData.sizes = await resizeAndSave(req, file.data, dimensions, staticPath, collectionConfig, fsSafeName, fileData.mimeType); + fileData.sizes = await resizeAndSave({ + req, + file: file.data, + dimensions, + staticPath, + config: collectionConfig, + savedFilename: fsSafeName, + mimeType: fileData.mimeType, + }); } } } catch (err) { diff --git a/src/collections/operations/delete.ts b/src/collections/operations/delete.ts index b7138084e6..091acbe401 100644 --- a/src/collections/operations/delete.ts +++ b/src/collections/operations/delete.ts @@ -5,7 +5,7 @@ import { PayloadRequest } from '../../express/types'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; import { NotFound, Forbidden, ErrorDeletingFile } from '../../errors'; import executeAccess from '../../auth/executeAccess'; -import fileExists from '../../uploads/fileExists'; +import fileOrDocExists from '../../uploads/fileOrDocExists'; import { BeforeOperationHook, Collection } from '../config/types'; import { Document, Where } from '../../types'; import { hasWhereAccessResult } from '../../auth/types'; @@ -46,7 +46,6 @@ async function deleteQuery(incomingArgs: Arguments): Promise { req, req: { locale, - fallbackLocale, }, overrideAccess, showHiddenFields, @@ -106,7 +105,8 @@ async function deleteQuery(incomingArgs: Arguments): Promise { const staticPath = path.resolve(this.config.paths.configDir, staticDir); const fileToDelete = `${staticPath}/${resultToDelete.filename}`; - if (await fileExists(fileToDelete)) { + + if (await fileOrDocExists(Model, staticPath, resultToDelete.filename)) { fs.unlink(fileToDelete, (err) => { if (err) { throw new ErrorDeletingFile(); @@ -116,7 +116,7 @@ async function deleteQuery(incomingArgs: Arguments): Promise { if (resultToDelete.sizes) { Object.values(resultToDelete.sizes).forEach(async (size: FileData) => { - if (await fileExists(`${staticPath}/${size.filename}`)) { + if (await fileOrDocExists(Model, staticPath, size.filename)) { fs.unlink(`${staticPath}/${size.filename}`, (err) => { if (err) { throw new ErrorDeletingFile(); diff --git a/src/collections/operations/find.ts b/src/collections/operations/find.ts index 983280d49a..919527fe7c 100644 --- a/src/collections/operations/find.ts +++ b/src/collections/operations/find.ts @@ -2,7 +2,7 @@ import { Where } from '../../types'; import { PayloadRequest } from '../../express/types'; import executeAccess from '../../auth/executeAccess'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; -import { Collection, PaginatedDocs } from '../config/types'; +import { Collection, TypeWithID, PaginatedDocs } from '../config/types'; import { hasWhereAccessResult } from '../../auth/types'; import flattenWhereConstraints from '../../utilities/flattenWhereConstraints'; @@ -18,7 +18,7 @@ export type Arguments = { showHiddenFields?: boolean } -async function find(incomingArgs: Arguments): Promise { +async function find(incomingArgs: Arguments): Promise> { let args = incomingArgs; // ///////////////////////////////////// @@ -145,7 +145,7 @@ async function find(incomingArgs: Arguments): Promise { return docRef; })), - } as PaginatedDocs; + } as PaginatedDocs; // ///////////////////////////////////// // afterRead - Fields @@ -195,7 +195,7 @@ async function find(incomingArgs: Arguments): Promise { result = { ...result, - docs: result.docs.map((doc) => sanitizeInternalFields(doc)), + docs: result.docs.map((doc) => sanitizeInternalFields(doc)), }; return result; diff --git a/src/collections/operations/findByID.ts b/src/collections/operations/findByID.ts index b09a6a310d..fbc11846df 100644 --- a/src/collections/operations/findByID.ts +++ b/src/collections/operations/findByID.ts @@ -1,11 +1,11 @@ /* eslint-disable no-underscore-dangle */ import memoize from 'micro-memoize'; import { PayloadRequest } from '../../express/types'; -import { Collection } from '../config/types'; +import { Collection, TypeWithID } from '../config/types'; import sanitizeInternalFields from '../../utilities/sanitizeInternalFields'; import { Forbidden, NotFound } from '../../errors'; import executeAccess from '../../auth/executeAccess'; -import { Document, Where } from '../../types'; +import { Where } from '../../types'; import { hasWhereAccessResult } from '../../auth/types'; export type Arguments = { @@ -19,7 +19,7 @@ export type Arguments = { depth?: number } -async function findByID(incomingArgs: Arguments): Promise { +async function findByID(incomingArgs: Arguments): Promise { let args = incomingArgs; // ///////////////////////////////////// diff --git a/src/collections/operations/local/create.ts b/src/collections/operations/local/create.ts index 3979301e03..c96638daea 100644 --- a/src/collections/operations/local/create.ts +++ b/src/collections/operations/local/create.ts @@ -12,6 +12,7 @@ export type Options = { disableVerificationEmail?: boolean showHiddenFields?: boolean filePath?: string + overwriteExistingFiles?: boolean } export default async function create(options: Options): Promise { const { @@ -25,6 +26,7 @@ export default async function create(options: Options): Promise { disableVerificationEmail, showHiddenFields, filePath, + overwriteExistingFiles = false, } = options; const collection = this.collections[collectionSlug]; @@ -36,6 +38,7 @@ export default async function create(options: Options): Promise { overrideAccess, disableVerificationEmail, showHiddenFields, + overwriteExistingFiles, req: { user, payloadAPI: 'local', diff --git a/src/collections/operations/local/delete.ts b/src/collections/operations/local/delete.ts index 337a74caa1..20b515ffdd 100644 --- a/src/collections/operations/local/delete.ts +++ b/src/collections/operations/local/delete.ts @@ -1,3 +1,4 @@ +import { TypeWithID } from '../../config/types'; import { Document } from '../../../types'; export type Options = { @@ -11,7 +12,7 @@ export type Options = { showHiddenFields?: boolean } -export default async function localDelete(options: Options): Promise { +export default async function localDelete(options: Options): Promise { const { collection: collectionSlug, depth, diff --git a/src/collections/operations/local/find.ts b/src/collections/operations/local/find.ts index cef864e294..117dd80eb7 100644 --- a/src/collections/operations/local/find.ts +++ b/src/collections/operations/local/find.ts @@ -1,4 +1,4 @@ -import { PaginatedDocs } from '../../config/types'; +import { PaginatedDocs, TypeWithID } from '../../config/types'; import { Document, Where } from '../../../types'; export type Options = { @@ -15,7 +15,7 @@ export type Options = { where?: Where } -export default async function find(options: Options): Promise { +export default async function find(options: Options): Promise> { const { collection: collectionSlug, depth, diff --git a/src/collections/operations/local/findByID.ts b/src/collections/operations/local/findByID.ts index 80189eda40..897d18170f 100644 --- a/src/collections/operations/local/findByID.ts +++ b/src/collections/operations/local/findByID.ts @@ -1,3 +1,4 @@ +import { TypeWithID } from '../../config/types'; import { PayloadRequest } from '../../../express/types'; import { Document } from '../../../types'; @@ -14,7 +15,7 @@ export type Options = { req?: PayloadRequest } -export default async function findByID(options: Options): Promise { +export default async function findByID(options: Options): Promise { const { collection: collectionSlug, depth, diff --git a/src/collections/operations/local/update.ts b/src/collections/operations/local/update.ts index c7823b2e25..a445fd1c6c 100644 --- a/src/collections/operations/local/update.ts +++ b/src/collections/operations/local/update.ts @@ -1,3 +1,4 @@ +import { TypeWithID } from '../../config/types'; import { Document } from '../../../types'; import getFileByPath from '../../../uploads/getFileByPath'; @@ -15,7 +16,7 @@ export type Options = { overwriteExistingFiles?: boolean } -export default async function update(options: Options): Promise { +export default async function update(options: Options): Promise { const { collection: collectionSlug, depth, diff --git a/src/collections/operations/update.ts b/src/collections/operations/update.ts index 9a140e0f3a..26aff65812 100644 --- a/src/collections/operations/update.ts +++ b/src/collections/operations/update.ts @@ -138,7 +138,7 @@ async function update(incomingArgs: Arguments): Promise { const file = ((req.files && req.files.file) ? req.files.file : req.file) as UploadedFile; if (file) { - const fsSafeName = !overwriteExistingFiles ? await getSafeFilename(staticPath, file.name) : file.name; + const fsSafeName = !overwriteExistingFiles ? await getSafeFilename(Model, staticPath, file.name) : file.name; try { if (!disableLocalStorage) { @@ -156,7 +156,15 @@ async function update(incomingArgs: Arguments): Promise { if (Array.isArray(imageSizes) && file.mimetype !== 'image/svg+xml') { req.payloadUploadSizes = {}; - fileData.sizes = await resizeAndSave(req, file.data, dimensions, staticPath, collectionConfig, fsSafeName, fileData.mimeType); + fileData.sizes = await resizeAndSave({ + req, + file: file.data, + dimensions, + staticPath, + config: collectionConfig, + savedFilename: fsSafeName, + mimeType: fileData.mimeType, + }); } } } catch (err) { diff --git a/src/collections/requestHandlers/find.ts b/src/collections/requestHandlers/find.ts index f5d23d29b2..028d4eeab0 100644 --- a/src/collections/requestHandlers/find.ts +++ b/src/collections/requestHandlers/find.ts @@ -1,9 +1,9 @@ import { Response, NextFunction } from 'express'; import httpStatus from 'http-status'; import { PayloadRequest } from '../../express/types'; -import { PaginatedDocs } from '../config/types'; +import { PaginatedDocs, TypeWithID } from '../config/types'; -export default async function find(req: PayloadRequest, res: Response, next: NextFunction): Promise | void> { +export default async function find(req: PayloadRequest, res: Response, next: NextFunction): Promise> | void> { try { let page; diff --git a/src/config/types.ts b/src/config/types.ts index 72c2b7dd22..e4561b712a 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -68,6 +68,10 @@ export type InitOptions = { }; export type AccessResult = boolean | Where; + +/** + * Access function + */ export type Access = (args?: any) => AccessResult; export type Config = { diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 801d69793f..10e9713f81 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -1,32 +1,32 @@ /* eslint-disable no-use-before-define */ import { CSSProperties } from 'react'; import { Editor } from 'slate'; +import { TypeWithID } from '../../collections/config/types'; import { PayloadRequest } from '../../express/types'; -import { Document } from '../../types'; import { ConditionalDateProps } from '../../admin/components/elements/DatePicker/types'; import { Description } from '../../admin/components/forms/FieldDescription/types'; -export type FieldHook = (args: { - value?: unknown, - originalDoc?: Document, - data?: { - [key: string]: unknown - }, +export type FieldHookArgs = { + value?: P, + originalDoc?: T, + data?: Partial, operation?: 'create' | 'read' | 'update' | 'delete', req: PayloadRequest -}) => Promise | unknown; +} -export type FieldAccess = (args: { +export type FieldHook = (args: FieldHookArgs) => Promise

| P; + +export type FieldAccess = (args: { req: PayloadRequest id?: string - data: Record - siblingData: Record + data: Partial + siblingData: Partial

}) => Promise | boolean; -export type Condition = (data: Record, siblingData: Record) => boolean; +export type Condition = (data: Partial, siblingData: Partial

) => boolean; type Admin = { - position?: string; + position?: 'sidebar'; width?: string; style?: CSSProperties; readOnly?: boolean; @@ -46,7 +46,7 @@ export type Labels = { plural: string; }; -export type Validate = (value: unknown, options?: any) => string | true | Promise; +export type Validate = (value?: T, options?: any) => string | true | Promise; export type OptionObject = { label: string @@ -99,6 +99,8 @@ export type TextField = FieldBase & { placeholder?: string autoComplete?: string } + value?: string + onChange?: (value: string) => void } export type EmailField = FieldBase & { @@ -167,9 +169,11 @@ export type UIField = { } export type UploadField = FieldBase & { - type: 'upload'; - relationTo: string; - maxDepth?: number; + type: 'upload' + relationTo: string + maxDepth?: number + value?: string + onChange?: (value: string) => void } type CodeAdmin = Admin & { @@ -184,9 +188,11 @@ export type CodeField = Omit & { } export type SelectField = FieldBase & { - type: 'select'; - options: Option[]; - hasMany?: boolean; + type: 'select' + options: Option[] + hasMany?: boolean + value?: string + onChange?: (value: string) => void } export type RelationshipField = FieldBase & { @@ -378,4 +384,4 @@ export function fieldAffectsData(field: Field): field is FieldAffectingData { return 'name' in field && !fieldIsPresentationalOnly(field); } -export type HookName = 'beforeChange' | 'beforeValidate' | 'afterChange' | 'afterRead'; +export type HookName = 'beforeRead' | 'beforeChange' | 'beforeValidate' | 'afterChange' | 'afterRead'; diff --git a/src/index.ts b/src/index.ts index 6759d73385..657401e5e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,15 @@ import express, { Express, Router } from 'express'; import crypto from 'crypto'; -import { Document } from 'mongoose'; +import { + TypeWithID, + Collection, CollectionModel, PaginatedDocs, +} from './collections/config/types'; import { SanitizedConfig, EmailOptions, InitOptions, } from './config/types'; -import { - Collection, CollectionModel, PaginatedDocs, -} from './collections/config/types'; + import Logger from './utilities/logger'; import bindOperations from './init/bindOperations'; import bindRequestHandlers, { RequestHandlers } from './init/bindRequestHandlers'; @@ -210,7 +211,7 @@ export class Payload { * @param options * @returns created document */ - create = async (options: CreateOptions): Promise => { + create = async (options: CreateOptions): Promise => { let { create } = localOperations; create = create.bind(this); return create(options); @@ -221,19 +222,19 @@ export class Payload { * @param options * @returns documents satisfying query */ - find = async (options: FindOptions): Promise => { + find = async (options: FindOptions): Promise> => { let { find } = localOperations; find = find.bind(this); return find(options); } - findGlobal = async (options): Promise => { + findGlobal = async (options): Promise => { let { findOne } = localGlobalOperations; findOne = findOne.bind(this); return findOne(options); } - updateGlobal = async (options): Promise => { + updateGlobal = async (options): Promise => { let { update } = localGlobalOperations; update = update.bind(this); return update(options); @@ -244,10 +245,10 @@ export class Payload { * @param options * @returns document with specified ID */ - findByID = async (options: FindByIDOptions): Promise => { + findByID = async (options: FindByIDOptions): Promise => { let { findByID } = localOperations; findByID = findByID.bind(this); - return findByID(options); + return findByID(options); } /** @@ -255,16 +256,16 @@ export class Payload { * @param options * @returns Updated document */ - update = async (options: UpdateOptions): Promise => { + update = async (options: UpdateOptions): Promise => { let { update } = localOperations; update = update.bind(this); - return update(options); + return update(options); } - delete = async (options: DeleteOptions): Promise => { + delete = async (options: DeleteOptions): Promise => { let { localDelete: deleteOperation } = localOperations; deleteOperation = deleteOperation.bind(this); - return deleteOperation(options); + return deleteOperation(options); } login = async (options): Promise => { diff --git a/src/revisions/tests/spec.ts b/src/revisions/tests/spec.ts index 487b50c1a6..cab5e141bb 100644 --- a/src/revisions/tests/spec.ts +++ b/src/revisions/tests/spec.ts @@ -49,7 +49,6 @@ describe('Revisions - REST', () => { }).then((res) => res.json()); expect(typeof revision.doc.id).toBe('string'); - expect(revision.doc._status).toBe('draft'); }); }); }); diff --git a/src/uploads/fileExists.ts b/src/uploads/fileExists.ts index 3d1b5d0d3a..08077524c9 100644 --- a/src/uploads/fileExists.ts +++ b/src/uploads/fileExists.ts @@ -3,11 +3,14 @@ import { promisify } from 'util'; const stat = promisify(fs.stat); -export default async (fileName: string): Promise => { +const fileExists = async (filename: string): Promise => { try { - await stat(fileName); + await stat(filename); + return true; } catch (err) { return false; } }; + +export default fileExists; diff --git a/src/uploads/fileOrDocExists.ts b/src/uploads/fileOrDocExists.ts new file mode 100644 index 0000000000..4bd2d8a61e --- /dev/null +++ b/src/uploads/fileOrDocExists.ts @@ -0,0 +1,20 @@ +import fs from 'fs'; +import { promisify } from 'util'; +import { CollectionModel } from '../collections/config/types'; + +const stat = promisify(fs.stat); + +const fileOrDocExists = async (Model: CollectionModel, path: string, filename: string): Promise => { + try { + const doc = await Model.findOne({ filename }); + if (doc) return true; + + await stat(`${path}/${filename}`); + + return true; + } catch (err) { + return false; + } +}; + +export default fileOrDocExists; diff --git a/src/uploads/getBaseFields.ts b/src/uploads/getBaseFields.ts index 48ecda9af4..deba9d2f31 100644 --- a/src/uploads/getBaseFields.ts +++ b/src/uploads/getBaseFields.ts @@ -67,6 +67,7 @@ const getBaseUploadFields = ({ config, collection }: Options): Field[] => { label: 'File Name', type: 'text', index: true, + unique: true, admin: { readOnly: true, disabled: true, diff --git a/src/uploads/getSafeFilename.ts b/src/uploads/getSafeFilename.ts index ef3c67ae88..298a24f446 100644 --- a/src/uploads/getSafeFilename.ts +++ b/src/uploads/getSafeFilename.ts @@ -1,5 +1,6 @@ import sanitize from 'sanitize-filename'; -import fileExists from './fileExists'; +import { CollectionModel } from '../collections/config/types'; +import fileOrDocExists from './fileOrDocExists'; const incrementName = (name: string) => { const extension = name.split('.').pop(); @@ -19,11 +20,11 @@ const incrementName = (name: string) => { return `${incrementedName}.${extension}`; }; -async function getSafeFileName(staticPath: string, desiredFilename: string): Promise { +async function getSafeFileName(Model: CollectionModel, staticPath: string, desiredFilename: string): Promise { let modifiedFilename = desiredFilename; // eslint-disable-next-line no-await-in-loop - while (await fileExists(`${staticPath}/${modifiedFilename}`)) { + while (await fileOrDocExists(Model, staticPath, modifiedFilename)) { modifiedFilename = incrementName(modifiedFilename); } return modifiedFilename; diff --git a/src/uploads/imageResizer.ts b/src/uploads/imageResizer.ts index 21534ed470..0d64190ec8 100644 --- a/src/uploads/imageResizer.ts +++ b/src/uploads/imageResizer.ts @@ -7,6 +7,16 @@ import { SanitizedCollectionConfig } from '../collections/config/types'; import { FileSizes, ImageSize } from './types'; import { PayloadRequest } from '../express/types'; +type Args = { + req: PayloadRequest, + file: Buffer, + dimensions: ProbedImageSize, + staticPath: string, + config: SanitizedCollectionConfig, + savedFilename: string, + mimeType: string, +} + function getOutputImage(sourceImage: string, size: ImageSize) { const extension = sourceImage.split('.').pop(); const name = sanitize(sourceImage.substr(0, sourceImage.lastIndexOf('.')) || sourceImage); @@ -27,15 +37,15 @@ function getOutputImage(sourceImage: string, size: ImageSize) { * @param mimeType * @returns image sizes keyed to strings */ -export default async function resizeAndSave( - req: PayloadRequest, - file: Buffer, - dimensions: ProbedImageSize, - staticPath: string, - config: SanitizedCollectionConfig, - savedFilename: string, - mimeType: string, -): Promise { +export default async function resizeAndSave({ + req, + file, + dimensions, + staticPath, + config, + savedFilename, + mimeType, +}: Args): Promise { const { imageSizes, disableLocalStorage } = config.upload; const sizes = imageSizes diff --git a/src/utilities/sanitizeInternalFields.ts b/src/utilities/sanitizeInternalFields.ts index 9b21b561a5..451337c927 100644 --- a/src/utilities/sanitizeInternalFields.ts +++ b/src/utilities/sanitizeInternalFields.ts @@ -1,6 +1,8 @@ +import { TypeWithID } from '../collections/config/types'; + const internalFields = ['__v', 'salt', 'hash']; -const sanitizeInternalFields = (incomingDoc) => Object.entries(incomingDoc).reduce((newDoc, [key, val]) => { +const sanitizeInternalFields = (incomingDoc): T => Object.entries(incomingDoc).reduce((newDoc, [key, val]): T => { if (key === '_id') { return { ...newDoc, @@ -16,6 +18,6 @@ const sanitizeInternalFields = (incomingDoc) => Object.entries(incomingDoc).redu ...newDoc, [key]: val, }; -}, {}); +}, {} as T); export default sanitizeInternalFields; diff --git a/types.d.ts b/types.d.ts index 5f42216a2a..1a6721b7e2 100644 --- a/types.d.ts +++ b/types.d.ts @@ -16,5 +16,21 @@ export { AfterForgotPasswordHook as CollectionAfterForgotPasswordHook, } from './dist/collections/config/types'; -export { GlobalConfig, SanitizedGlobalConfig } from './dist/globals/config/types'; -export { Field, FieldHook, FieldAccess, RichTextCustomElement, RichTextCustomLeaf, Block } from './dist/fields/config/types'; +export { + GlobalConfig, + SanitizedGlobalConfig, + BeforeValidateHook as GlobalBeforeValidateHook, + BeforeChangeHook as GlobalBeforeChangeHook, + AfterChangeHook as GlobalAfterChangeHook, + BeforeReadHook as GlobalBeforeReadHook, + AfterReadHook as GlobalAfterReadHook, +} from './dist/globals/config/types'; + +export { + Field, + FieldHook, + FieldAccess, + RichTextCustomElement, + RichTextCustomLeaf, + Block, +} from './dist/fields/config/types';