diff --git a/demo/collections/AutoLabel.ts b/demo/collections/AutoLabel.ts index 67452083f7..ffd4ca99df 100644 --- a/demo/collections/AutoLabel.ts +++ b/demo/collections/AutoLabel.ts @@ -2,11 +2,60 @@ import { PayloadCollectionConfig } from '../../src/collections/config/types'; const AutoLabel: PayloadCollectionConfig = { slug: 'auto-label', - fields: [{ - name: 'text', - type: 'text', - label: 'Text', - }], + fields: [ + { + name: 'autoLabelField', + type: 'text', + }, + { + name: 'noLabel', + type: 'text', + label: false, + }, + { + name: 'labelOverride', + type: 'text', + label: 'Custom Label', + }, + { + name: 'specialBlock', + type: 'blocks', + minRows: 1, + maxRows: 20, + // Will auto-label + // labels: { + // singular: 'Special Block', + // plural: 'Special Blocks', + // }, + blocks: [ + { + slug: 'number', + // Will auto-label + // labels: { + // singular: 'Number', + // plural: 'Numbers', + // }, + fields: [ + { + name: 'testNumber', + type: 'number', + }, + ], + }, + ], + }, + { + name: 'noLabelArray', + type: 'array', + label: false, + fields: [ + { + type: 'text', + name: 'textField', + }, + ], + }, + ], }; export default AutoLabel; diff --git a/demo/collections/LocalizedArray.ts b/demo/collections/LocalizedArray.ts index 7f82514656..c37d404e37 100644 --- a/demo/collections/LocalizedArray.ts +++ b/demo/collections/LocalizedArray.ts @@ -24,7 +24,7 @@ const LocalizedArrays: PayloadCollectionConfig = { fields: [ { type: 'array', - label: 'Array', + label: false, name: 'array', localized: true, required: true, diff --git a/demo/collections/RelationshipB.ts b/demo/collections/RelationshipB.ts index 27fc3a736f..bced99d8c9 100644 --- a/demo/collections/RelationshipB.ts +++ b/demo/collections/RelationshipB.ts @@ -26,6 +26,13 @@ const RelationshipB: PayloadCollectionConfig = { localized: true, hasMany: false, }, + { + name: 'localizedPosts', + label: 'Localized Posts', + type: 'relationship', + hasMany: true, + relationTo: 'localized-posts', + }, ], timestamps: true, }; diff --git a/demo/globals/NavigationArray.ts b/demo/globals/NavigationArray.ts index bf17f132de..aa3151a182 100644 --- a/demo/globals/NavigationArray.ts +++ b/demo/globals/NavigationArray.ts @@ -3,7 +3,6 @@ import checkRole from '../access/checkRole'; export default { slug: 'navigation-array', - label: 'Navigation Array', access: { update: ({ req: { user } }) => checkRole(['admin', 'user'], user), read: () => true, diff --git a/docs/access-control/fields.mdx b/docs/access-control/fields.mdx index 0e8f74cd94..3260d9b51b 100644 --- a/docs/access-control/fields.mdx +++ b/docs/access-control/fields.mdx @@ -23,7 +23,6 @@ export default { fields: [ { name: 'title', - label: 'Title', type: 'text', // highlight-start access: { diff --git a/docs/configuration/collections.mdx b/docs/configuration/collections.mdx index 95854ba408..c78b83b731 100644 --- a/docs/configuration/collections.mdx +++ b/docs/configuration/collections.mdx @@ -16,8 +16,8 @@ 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. | -| **`admin`** | Admin-specific configuration. See below for [more detail](/docs/admin/overview#admin-options). | +| **`labels`** | Singular and plural labels for use in identifying this Collection throughout Payload. Auto-generated from slug if not defined. | +| **`admin`** | Admin-specific configuration. See below for [more detail](/docs/collections#admin). | | **`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) | | **`auth`** | Specify options if you would like this Collection to feature authentication. For more, consult the [Authentication](/docs/authentication/config) documentation. | @@ -29,12 +29,8 @@ It's often best practice to write your Collections in separate files and then im #### Simple collection example ```js -const Order = { +const Orders = { slug: 'orders', - labels: { - singular: 'Order', - plural: 'Orders', - }, fields: [ { name: 'total', diff --git a/docs/configuration/globals.mdx b/docs/configuration/globals.mdx index c8534c3c0d..de134517d1 100644 --- a/docs/configuration/globals.mdx +++ b/docs/configuration/globals.mdx @@ -16,7 +16,7 @@ As with Collection configs, it's often best practice to write your Globals in se | ---------------- | -------------| | **`slug`** * | Unique, URL-friendly string that will act as an identifier for this Global. | | **`fields`** * | Array of field types that will determine the structure and functionality of the data stored within this Global. [Click here](/docs/fields/overview) for a full list of field types as well as how to configure them. | -| **`label`** | Singular label for use in identifying this Global throughout Payload. | +| **`label`** | Singular label for use in identifying this Global throughout Payload. Auto-generated from slug if not defined. | | **`admin`** | Admin-specific configuration. See below for [more detail](/docs/configuration/globals#admin-options). | | **`hooks`** | Entry points to "tie in" to collection actions at specific points. [More](/docs/hooks/overview#global-hooks) | | **`access`** | Provide access control functions to define exactly who should be able to do what with this Global. [More](/docs/access-control/overview/#globals) | @@ -28,7 +28,6 @@ As with Collection configs, it's often best practice to write your Globals in se ```js const Nav = { slug: 'nav', - label: 'Nav', fields: [ { name: 'items', @@ -38,7 +37,6 @@ const Nav = { fields: [ { name: 'page', - label: 'Page', type: 'relationship', relationTo: 'pages', // "pages" is the slug of an existing collection required: true, diff --git a/docs/configuration/localization.mdx b/docs/configuration/localization.mdx index 4eef5e2964..9b4eef1610 100644 --- a/docs/configuration/localization.mdx +++ b/docs/configuration/localization.mdx @@ -55,7 +55,6 @@ Payload localization works on a **field** level—not a document level. In addit ```js { name: 'title', - label: 'Page Title', type: 'text', // highlight-start localized: true, diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index 2e6e55f826..fc088a9e6f 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -50,13 +50,11 @@ const config = buildConfig({ fields: [ { name: 'title', - label: 'Title', type: 'text', required: true, }, { name: 'content', - label: 'Content', type: 'richText', required: true, } @@ -66,16 +64,13 @@ const config = buildConfig({ globals: [ { slug: 'header', - label: 'Header', fields: [ { name: 'nav', - label: 'Nav', type: 'array', fields: [ { name: 'page', - label: 'Page', type: 'relationship', relationTo: 'pages', }, diff --git a/docs/fields/array.mdx b/docs/fields/array.mdx index d00ed02094..76debefb31 100644 --- a/docs/fields/array.mdx +++ b/docs/fields/array.mdx @@ -24,7 +24,7 @@ keywords: array, fields, config, configuration, documentation, Content Managemen | Option | Description | | ---------------- | ----------- | | **`name`** * | To be used as the property name when stored and retrieved from the database. | -| **`label`** | Used as a heading in the Admin panel and to name the generated GraphQL type. | +| **`label`** | Used as a heading in the Admin panel and to name the generated GraphQL type. Auto-generated from name if not defined. | | **`fields`** * | Array of field types to correspond to each row of the Array. | | **`validate`** | Provide a custom validation function that will be executed on both the Admin panel and the backend. [More](/docs/fields/overview#validation) | | **`saveToJWT`** | If this field is top-level and nested in a config supporting [Authentication](/docs/authentication/config), include its data in the user JWT. | @@ -59,14 +59,12 @@ keywords: array, fields, config, configuration, documentation, Content Managemen fields: [ // required { name: 'image', - label: 'Image', type: 'upload', relationTo: 'media', required: true, }, { name: 'caption', - label: 'Caption', type: 'text', } ] diff --git a/docs/fields/blocks.mdx b/docs/fields/blocks.mdx index f84f7dfb3b..2a48012ba0 100644 --- a/docs/fields/blocks.mdx +++ b/docs/fields/blocks.mdx @@ -53,7 +53,7 @@ Blocks are defined as separate configs of their own. | ---------------- | ----------- | | **`slug`** * | Identifier for this block type. Will be saved on each block as the `blockType` property. | | **`fields`** * | Array of fields to be stored in this block. | -| **`labels`** | Customize the block labels that appear in the Admin dashboard. Also used to name corresponding GraphQL schema types. | +| **`labels`** | Customize the block labels that appear in the Admin dashboard. Also used to name corresponding GraphQL schema types. Auto-generated from slug if not defined. | | **`imageURL`** | Provide a custom image thumbnail to help editors identify this block in the Admin UI. | | **`imageAltText`** | Customize this block's image thumbnail alt text. | @@ -79,14 +79,12 @@ const QuoteBlock = { imageAltText: 'A nice thumbnail image to show what this block looks like', fields: [ // required { - name: 'text', - label: 'Quote Text', + name: 'quoteHeader', type: 'text', required: true, }, { - name: 'text', - label: 'Quotee', + name: 'quoteText', type: 'text', }, ] @@ -98,13 +96,8 @@ const ExampleCollection = { { name: 'layout', // required type: 'blocks', // required - label: 'layout', minRows: 1, maxRows: 20, - labels: { - singular: 'Layout', - plural: 'Layouts', - }, blocks: [ // required QuoteBlock ] diff --git a/docs/fields/code.mdx b/docs/fields/code.mdx index 67ad38a2fa..5f5b1a49ce 100644 --- a/docs/fields/code.mdx +++ b/docs/fields/code.mdx @@ -51,7 +51,6 @@ Currently, the `language` property only supports JavaScript syntax but more supp { name: 'trackingCode', // required type: 'code', // required - label: 'Tracking Code', required: true, admin: { language: 'js' diff --git a/docs/fields/group.mdx b/docs/fields/group.mdx index 5ff7b1fa81..6c355e1e9d 100644 --- a/docs/fields/group.mdx +++ b/docs/fields/group.mdx @@ -37,9 +37,8 @@ keywords: group, fields, config, configuration, documentation, Content Managemen slug: 'example-collection', fields: [ { - name: 'meta', // required + name: 'pageMeta', // required type: 'group', // required - label: 'Page Meta', fields: [ // required { name: 'title', diff --git a/docs/fields/number.mdx b/docs/fields/number.mdx index 1b9d1dd245..f5054d174d 100644 --- a/docs/fields/number.mdx +++ b/docs/fields/number.mdx @@ -58,7 +58,6 @@ Set this property to a string that will be used for browser autocomplete. { name: 'age', // required type: 'number', // required - label: 'Age', required: true, admin: { step: 1, diff --git a/docs/fields/overview.mdx b/docs/fields/overview.mdx index 3d0f713a66..3ae8097e5a 100644 --- a/docs/fields/overview.mdx +++ b/docs/fields/overview.mdx @@ -20,14 +20,12 @@ const Pages = { slug: 'pages', fields: [ { - name: 'my-field', + name: 'myField', type: 'text', // highlight-line - label: 'My Field', }, { - name: 'other-field', + name: 'otherField', type: 'checkbox', // highlight-line - label: 'Other Field' }, ], } diff --git a/docs/fields/radio.mdx b/docs/fields/radio.mdx index b5684a9ea6..3527a7b27b 100644 --- a/docs/fields/radio.mdx +++ b/docs/fields/radio.mdx @@ -53,7 +53,6 @@ The `layout` property allows for the radio group to be styled as a horizonally o { name: 'color', // required type: 'radio', // required - label: 'color', options: [ // required { label: 'Mint', diff --git a/docs/fields/relationship.mdx b/docs/fields/relationship.mdx index 442094c7f9..a5681e9c53 100644 --- a/docs/fields/relationship.mdx +++ b/docs/fields/relationship.mdx @@ -54,7 +54,6 @@ keywords: relationship, fields, config, configuration, documentation, Content Ma name: 'placedBy', // required type: 'relationship', // required relationTo: ['organizations', 'users'], // required - label: 'Placed By', hasMany: false, required: true, } diff --git a/docs/fields/text.mdx b/docs/fields/text.mdx index e7b14e7f5b..c2c04c507d 100644 --- a/docs/fields/text.mdx +++ b/docs/fields/text.mdx @@ -52,9 +52,8 @@ Set this property to a string that will be used for browser autocomplete. slug: 'example-collection', fields: [ { - name: 'title', // required + name: 'pageTitle', // required type: 'text', // required - label: 'Page Title', required: true, } ] diff --git a/docs/fields/textarea.mdx b/docs/fields/textarea.mdx index 891a0c2959..3fb0b0e976 100644 --- a/docs/fields/textarea.mdx +++ b/docs/fields/textarea.mdx @@ -54,7 +54,6 @@ Set this property to a string that will be used for browser autocomplete. { name: 'metaDescription', // required type: 'textarea', // required - label: 'Page Meta Description', required: true, } ] diff --git a/docs/fields/upload.mdx b/docs/fields/upload.mdx index 7f93eabf8e..91e16f7796 100644 --- a/docs/fields/upload.mdx +++ b/docs/fields/upload.mdx @@ -50,10 +50,9 @@ keywords: upload, images media, fields, config, configuration, documentation, Co slug: 'example-collection', fields: [ { - name: 'background', // required + name: 'backgroundImage', // required type: 'upload', // required relationTo: 'media', // required - label: 'Background Image', required: true, } ] diff --git a/docs/getting-started/concepts.mdx b/docs/getting-started/concepts.mdx index bf04d88816..ee623df749 100644 --- a/docs/getting-started/concepts.mdx +++ b/docs/getting-started/concepts.mdx @@ -80,7 +80,6 @@ You can specify population `depth` via query parameter in the REST API and by an fields: [ { name: 'title', - label: 'Title', type: 'text', }, { @@ -97,12 +96,10 @@ You can specify population `depth` via query parameter in the REST API and by an fields: [ { name: 'email', - label: 'Email', type: 'email', }, { name: 'department' - label: 'Department', type: 'relationship', relationTo: 'departments' } @@ -114,7 +111,6 @@ You can specify population `depth` via query parameter in the REST API and by an fields: [ { name: 'name' - label: 'Name', type: 'text', } ] diff --git a/docs/graphql/overview.mdx b/docs/graphql/overview.mdx index 8df6d95776..9811331d7e 100644 --- a/docs/graphql/overview.mdx +++ b/docs/graphql/overview.mdx @@ -60,7 +60,6 @@ Globals are also fully supported. For example: ```js const Header = { slug: 'header', - label: 'Header', fields: [ ... ], diff --git a/docs/hooks/collections.mdx b/docs/hooks/collections.mdx index 488fc7b848..ec0522007e 100644 --- a/docs/hooks/collections.mdx +++ b/docs/hooks/collections.mdx @@ -32,7 +32,7 @@ All collection Hook properties accept arrays of synchronous or asynchronous func module.exports = { slug: 'example-hooks', fields: [ - { name: 'name', label: 'Name', type: 'text'}, + { name: 'name', type: 'text'}, ] hooks: { beforeOperation: [(args) => {...}], diff --git a/docs/hooks/fields.mdx b/docs/hooks/fields.mdx index c45eba50c2..3433e25714 100644 --- a/docs/hooks/fields.mdx +++ b/docs/hooks/fields.mdx @@ -29,7 +29,6 @@ Example field configuration: ```js { name: 'name', - label: 'Name', type: 'text', // highlight-start hooks: { diff --git a/docs/hooks/globals.mdx b/docs/hooks/globals.mdx index b89f33d1fa..cdfb919b7c 100644 --- a/docs/hooks/globals.mdx +++ b/docs/hooks/globals.mdx @@ -24,7 +24,7 @@ All Global Hook properties accept arrays of synchronous or asynchronous function module.exports = { slug: 'header', fields: [ - { name: 'title', label: 'Title', type: 'text'}, + { name: 'title', type: 'text'}, ] hooks: { beforeValidate: [(args) => {...}], @@ -38,43 +38,40 @@ module.exports = { ### beforeValidate -Runs before the `create` and `update` operations. This hook allows you to add or format data before the incoming data is validated. +Runs before the `update` operation. This hook allows you to add or format data before the incoming data is validated. ```js const beforeValidateHook = async ({ data, // incoming data to update or create with req, // full express request - operation, // name of the operation ie. 'create', 'update' originalDoc, // original document }) => { - return data; // Return data to either create or update a document with + return data; // Return data to update the document with } ``` ### beforeChange -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. +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 ({ data, // incoming data to update or create with req, // full express request - operation, // name of the operation ie. 'create', 'update' originalDoc, // original document }) => { - return data; // Return data to either create or update a document with + return data; // Return data to update the document with } ``` ### afterChange -After a global is created or updated, the `afterChange` hook runs. Use this hook to purge caches of your applications, sync site data to CRMs, and more. +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 ({ doc, // full document data req, // full express request - operation, // name of the operation ie. 'create', 'update' }) => { return data; } diff --git a/src/admin/components/elements/ColumnSelector/index.tsx b/src/admin/components/elements/ColumnSelector/index.tsx index 2dd28b3bd3..3cd5a70851 100644 --- a/src/admin/components/elements/ColumnSelector/index.tsx +++ b/src/admin/components/elements/ColumnSelector/index.tsx @@ -69,7 +69,7 @@ const ColumnSelector: React.FC = (props) => { pillStyle={isEnabled ? 'dark' : undefined} className={`${baseClass}__active-column`} > - {field.label} + {field.label || field.name} ); })} diff --git a/src/admin/components/forms/Label/types.ts b/src/admin/components/forms/Label/types.ts index 970fd16d25..26e7124879 100644 --- a/src/admin/components/forms/Label/types.ts +++ b/src/admin/components/forms/Label/types.ts @@ -1,5 +1,5 @@ export type Props = { - label?: string | JSX.Element + label?: string | false | JSX.Element required?: boolean htmlFor?: string } diff --git a/src/admin/components/forms/field-types/Array/Array.tsx b/src/admin/components/forms/field-types/Array/Array.tsx index 468db62ec1..a2784e93b6 100644 --- a/src/admin/components/forms/field-types/Array/Array.tsx +++ b/src/admin/components/forms/field-types/Array/Array.tsx @@ -184,7 +184,7 @@ const RenderArray = React.memo((props: RenderArrayProps) => { key={row.key} id={row.key} blockType="array" - label={label} + label={labels.singular} isOpen={row.open} rowCount={rows.length} rowIndex={i} diff --git a/src/admin/components/forms/field-types/Array/types.ts b/src/admin/components/forms/field-types/Array/types.ts index 0a29e0a8b1..d9408fed57 100644 --- a/src/admin/components/forms/field-types/Array/types.ts +++ b/src/admin/components/forms/field-types/Array/types.ts @@ -7,6 +7,7 @@ export type Props = Omit & { path?: string fieldTypes: FieldTypes permissions: FieldPermissions + label: string | false } export type RenderArrayProps = { @@ -16,7 +17,7 @@ export type RenderArrayProps = { fields: Field[] permissions: FieldPermissions onDragEnd: (result: any) => void - label: string + label: string | false value: number readOnly: boolean minRows: number diff --git a/src/admin/components/forms/field-types/Blocks/types.ts b/src/admin/components/forms/field-types/Blocks/types.ts index 0df38e9d09..0d8a26c0b3 100644 --- a/src/admin/components/forms/field-types/Blocks/types.ts +++ b/src/admin/components/forms/field-types/Blocks/types.ts @@ -15,7 +15,7 @@ export type RenderBlockProps = { fieldTypes: FieldTypes permissions: FieldPermissions onDragEnd: (result: any) => void - label: string + label: string | false value: number readOnly: boolean minRows: number diff --git a/src/admin/components/views/collections/List/Cell/cellTypes.spec.tsx b/src/admin/components/views/collections/List/Cell/cellTypes.spec.tsx index 8819215908..5f87bae6ca 100644 --- a/src/admin/components/views/collections/List/Cell/cellTypes.spec.tsx +++ b/src/admin/components/views/collections/List/Cell/cellTypes.spec.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/jsx-max-props-per-line */ import React from 'react'; import { render } from '@testing-library/react'; import BlocksCell from './field-types/Blocks'; @@ -13,6 +12,7 @@ describe('Cell Types', () => { name: 'blocks', labels: { singular: 'Block', + plural: 'Blocks Content', }, type: 'blocks', blocks: [ @@ -30,14 +30,20 @@ describe('Cell Types', () => { { blockType: 'number' }, { blockType: 'number' }, ]; - const { container } = render(); + const { container } = render(); const el = container.querySelector('span'); expect(el).toHaveTextContent('2 Blocks Content - Number, Number'); }); it('renders zero', () => { const data = []; - const { container } = render(); + const { container } = render(); const el = container.querySelector('span'); expect(el).toHaveTextContent('0 Blocks Content'); }); @@ -52,7 +58,10 @@ describe('Cell Types', () => { { blockType: 'number' }, ]; - const { container } = render(); + const { container } = render(); const el = container.querySelector('span'); expect(el).toHaveTextContent('6 Blocks Content - Number, Number, Number, Number, Number and 1 more'); }); diff --git a/src/admin/components/views/collections/List/Cell/field-types/Array/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/Array/index.tsx index 3de08fe312..4429108b06 100644 --- a/src/admin/components/views/collections/List/Cell/field-types/Array/index.tsx +++ b/src/admin/components/views/collections/List/Cell/field-types/Array/index.tsx @@ -1,8 +1,14 @@ import React from 'react'; +import { ArrayField } from '../../../../../../../../fields/config/types'; -const ArrayCell = ({ data, field }) => { +type Props = { + data: Record + field: ArrayField +} + +const ArrayCell: React.FC = ({ data, field }) => { const arrayFields = data ?? []; - const label = `${arrayFields.length} ${field.label} rows`; + const label = `${arrayFields.length} ${field?.labels?.plural || 'Rows'}`; return ( {label} diff --git a/src/admin/components/views/collections/List/Cell/field-types/Blocks/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/Blocks/index.tsx index 4581e9532e..96e59a61ef 100644 --- a/src/admin/components/views/collections/List/Cell/field-types/Blocks/index.tsx +++ b/src/admin/components/views/collections/List/Cell/field-types/Blocks/index.tsx @@ -4,7 +4,7 @@ const BlocksCell = ({ data, field }) => { const selectedBlocks = data ? data.map(({ blockType }) => blockType) : []; const blockLabels = field.blocks.map((s) => ({ slug: s.slug, label: s.labels.singular })); - let label = `0 ${field.label}`; + let label = `0 ${field.labels.plural}`; const formatBlockList = (blocks) => blocks.map((b) => { const filtered = blockLabels.filter((f) => f.slug === b)?.[0]; @@ -14,9 +14,9 @@ const BlocksCell = ({ data, field }) => { const itemsToShow = 5; if (selectedBlocks.length > itemsToShow) { const more = selectedBlocks.length - itemsToShow; - label = `${selectedBlocks.length} ${field.label} - ${formatBlockList(selectedBlocks.slice(0, itemsToShow))} and ${more} more`; + label = `${selectedBlocks.length} ${field.labels.plural} - ${formatBlockList(selectedBlocks.slice(0, itemsToShow))} and ${more} more`; } else if (selectedBlocks.length > 0) { - label = `${selectedBlocks.length} ${field.label} - ${formatBlockList(selectedBlocks)}`; + label = `${selectedBlocks.length} ${selectedBlocks.length === 1 ? field.labels.singular : field.labels.plural} - ${formatBlockList(selectedBlocks)}`; } return ( diff --git a/src/admin/components/views/collections/List/Cell/index.tsx b/src/admin/components/views/collections/List/Cell/index.tsx index 4c0007c71a..eb3f2e93a0 100644 --- a/src/admin/components/views/collections/List/Cell/index.tsx +++ b/src/admin/components/views/collections/List/Cell/index.tsx @@ -36,7 +36,7 @@ const DefaultCell: React.FC = (props) => { if (!CellComponent) { return ( - {(cellData === '' || typeof cellData === 'undefined') && ``} + {(cellData === '' || typeof cellData === 'undefined') && ``} {typeof cellData === 'string' && cellData} {typeof cellData === 'number' && cellData} {typeof cellData === 'object' && JSON.stringify(cellData)} diff --git a/src/admin/components/views/collections/List/buildColumns.tsx b/src/admin/components/views/collections/List/buildColumns.tsx index 9f7ddd9230..79b2a001e9 100644 --- a/src/admin/components/views/collections/List/buildColumns.tsx +++ b/src/admin/components/views/collections/List/buildColumns.tsx @@ -51,7 +51,7 @@ const buildColumns = (collection: CollectionConfig, columns: string[], setSort: components: { Heading: ( { @@ -62,7 +62,7 @@ const sanitizeCollection = (collections: PayloadCollectionConfig[], collection: const sanitized: PayloadCollectionConfig = merge(defaults, collection); sanitized.slug = toKebabCase(sanitized.slug); - sanitized.labels = !sanitized.labels ? formatLabels(sanitized.slug) : sanitized.labels; + sanitized.labels = sanitized.labels || formatLabels(sanitized.slug); if (sanitized.upload) { if (sanitized.upload === true) sanitized.upload = {}; diff --git a/src/errors/MissingGlobalLabel.ts b/src/errors/MissingGlobalLabel.ts deleted file mode 100644 index 1e0603e23b..0000000000 --- a/src/errors/MissingGlobalLabel.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Config } from '../config/types'; -import APIError from './APIError'; - -class MissingGlobalLabel extends APIError { - constructor(config: Config) { - super(`${config.globals} object is missing label`); - } -} - -export default MissingGlobalLabel; diff --git a/src/errors/index.ts b/src/errors/index.ts index 35a4a9bcd7..4b13b58b79 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -13,6 +13,5 @@ export { default as MissingCollectionLabel } from './MissingCollectionLabel'; export { default as MissingFieldInputOptions } from './MissingFieldInputOptions'; export { default as MissingFieldType } from './MissingFieldType'; export { default as MissingFile } from './MissingFile'; -export { default as MissingGlobalLabel } from './MissingGlobalLabel'; export { default as NotFound } from './NotFound'; export { default as ValidationError } from './ValidationError'; diff --git a/src/fields/config/sanitize.spec.ts b/src/fields/config/sanitize.spec.ts index acfe53e222..8208be2066 100644 --- a/src/fields/config/sanitize.spec.ts +++ b/src/fields/config/sanitize.spec.ts @@ -1,5 +1,6 @@ import sanitizeFields from './sanitize'; import { MissingFieldType, InvalidFieldRelationship } from '../../errors'; +import { Block } from './types'; describe('sanitizeFields', () => { it('should throw on missing type field', () => { @@ -11,6 +12,39 @@ describe('sanitizeFields', () => { sanitizeFields(fields, []); }).toThrow(MissingFieldType); }); + it('should populate label if missing', () => { + const fields = [{ + name: 'someCollection', + type: 'text', + }]; + const sanitizedField = sanitizeFields(fields, [])[0]; + expect(sanitizedField.name).toStrictEqual('someCollection'); + expect(sanitizedField.label).toStrictEqual('Some Collection'); + expect(sanitizedField.type).toStrictEqual('text'); + }); + it('should allow auto-label override', () => { + const fields = [{ + name: 'someCollection', + type: 'text', + label: 'Do not label', + }]; + const sanitizedField = sanitizeFields(fields, [])[0]; + expect(sanitizedField.name).toStrictEqual('someCollection'); + expect(sanitizedField.label).toStrictEqual('Do not label'); + expect(sanitizedField.type).toStrictEqual('text'); + }); + it('should allow label opt-out', () => { + const fields = [{ + name: 'someCollection', + type: 'text', + label: false, + }]; + const sanitizedField = sanitizeFields(fields, [])[0]; + expect(sanitizedField.name).toStrictEqual('someCollection'); + expect(sanitizedField.label).toStrictEqual(false); + expect(sanitizedField.type).toStrictEqual('text'); + }); + describe('relationships', () => { it('should not throw on valid relationship', () => { @@ -41,6 +75,15 @@ describe('sanitizeFields', () => { it('should not throw on valid relationship inside blocks', () => { const validRelationships = ['some-collection']; + const relationshipBlock: Block = { + slug: 'relationshipBlock', + fields: [{ + type: 'relationship', + label: 'my-relationship', + name: 'My Relationship', + relationTo: 'some-collection', + }], + }; const fields = [{ name: 'layout', label: 'Layout Blocks', @@ -48,14 +91,7 @@ describe('sanitizeFields', () => { singular: 'Block', }, type: 'blocks', - blocks: [{ - fields: [{ - type: 'relationship', - label: 'my-relationship', - name: 'My Relationship', - relationTo: 'some-collection', - }], - }], + blocks: [relationshipBlock], }]; expect(() => { sanitizeFields(fields, validRelationships); @@ -90,6 +126,15 @@ describe('sanitizeFields', () => { it('should throw on invalid relationship inside blocks', () => { const validRelationships = ['some-collection']; + const relationshipBlock: Block = { + slug: 'relationshipBlock', + fields: [{ + type: 'relationship', + label: 'my-relationship', + name: 'My Relationship', + relationTo: 'not-valid', + }], + }; const fields = [{ name: 'layout', label: 'Layout Blocks', @@ -97,14 +142,7 @@ describe('sanitizeFields', () => { singular: 'Block', }, type: 'blocks', - blocks: [{ - fields: [{ - type: 'relationship', - label: 'my-relationship', - name: 'My Relationship', - relationTo: 'not-valid', - }], - }], + blocks: [relationshipBlock], }]; expect(() => { sanitizeFields(fields, validRelationships); diff --git a/src/fields/config/sanitize.ts b/src/fields/config/sanitize.ts index d164abb7d9..73ac21e9ef 100644 --- a/src/fields/config/sanitize.ts +++ b/src/fields/config/sanitize.ts @@ -1,7 +1,8 @@ +import { formatLabels, toWords } from '../../utilities/formatLabels'; import { MissingFieldType, InvalidFieldRelationship } from '../../errors'; import validations from '../validations'; -const sanitizeFields = (fields, validRelationships) => { +const sanitizeFields = (fields, validRelationships: string[]) => { if (!fields) return []; return fields.map((unsanitizedField) => { @@ -9,15 +10,24 @@ const sanitizeFields = (fields, validRelationships) => { if (!field.type) throw new MissingFieldType(field); + // Auto-label + if (field.name && typeof field.label !== 'string' && field.label !== false) { + field.label = toWords(field.name); + } + if (field.type === 'relationship') { const relationships = Array.isArray(field.relationTo) ? field.relationTo : [field.relationTo]; - relationships.forEach((relationship) => { + relationships.forEach((relationship: string) => { if (!validRelationships.includes(relationship)) { throw new InvalidFieldRelationship(field, relationship); } }); } + if (field.type === 'blocks') { + field.labels = field.labels || formatLabels(field.name); + } + if (typeof field.validate === 'undefined') { const defaultValidate = validations[field.type]; if (defaultValidate) { @@ -36,6 +46,7 @@ const sanitizeFields = (fields, validRelationships) => { if (field.blocks) { field.blocks = field.blocks.map((block) => { const unsanitizedBlock = { ...block }; + unsanitizedBlock.labels = !unsanitizedBlock.labels ? formatLabels(unsanitizedBlock.slug) : unsanitizedBlock.labels; unsanitizedBlock.fields = sanitizeFields(block.fields, validRelationships); return unsanitizedBlock; }); diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index 7a28321e0b..f3c41e815f 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -21,7 +21,10 @@ export const baseAdminFields = joi.object().keys({ }); export const baseField = joi.object().keys({ - label: joi.string(), + label: joi.alternatives().try( + joi.string(), + joi.valid(false), + ), required: joi.boolean().default(false), saveToJWT: joi.boolean().default(false), unique: joi.boolean().default(false), diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 450b3ef4eb..ccfc61eb9a 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -51,7 +51,7 @@ export type Option = OptionObject | string export interface FieldBase { name?: string; - label?: string; + label?: string | false; required?: boolean; unique?: boolean; index?: boolean; @@ -209,7 +209,7 @@ export type RadioField = FieldBase & { export type Block = { slug: string, - labels: Labels + labels?: Labels fields: Field[], imageURL?: string imageAltText?: string diff --git a/src/globals/config/sanitize.ts b/src/globals/config/sanitize.ts index 619a68f48f..d3fdb518ff 100644 --- a/src/globals/config/sanitize.ts +++ b/src/globals/config/sanitize.ts @@ -1,20 +1,14 @@ -import { MissingGlobalLabel } from '../../errors'; +import { toWords } from '../../utilities/formatLabels'; +import { PayloadCollectionConfig } from '../../collections/config/types'; import sanitizeFields from '../../fields/config/sanitize'; +import { PayloadGlobalConfig, GlobalConfig } from './types'; -const sanitizeGlobals = (collections, globals) => { - // ///////////////////////////////// - // Ensure globals are valid - // ///////////////////////////////// - - globals.forEach((globalConfig) => { - if (!globalConfig.label) { - throw new MissingGlobalLabel(globalConfig); - } - }); - +const sanitizeGlobals = (collections: PayloadCollectionConfig[], globals: PayloadGlobalConfig[]): GlobalConfig[] => { const sanitizedGlobals = globals.map((global) => { const sanitizedGlobal = { ...global }; + sanitizedGlobal.label = sanitizedGlobal.label || toWords(sanitizedGlobal.slug); + // ///////////////////////////////// // Ensure that collection has required object structure // ///////////////////////////////// @@ -36,7 +30,7 @@ const sanitizeGlobals = (collections, globals) => { const validRelationships = collections.map((c) => c.slug); sanitizedGlobal.fields = sanitizeFields(global.fields, validRelationships); - return sanitizedGlobal; + return sanitizedGlobal as GlobalConfig; }); return sanitizedGlobals; diff --git a/src/globals/config/schema.ts b/src/globals/config/schema.ts index 372e3faa74..65717df64a 100644 --- a/src/globals/config/schema.ts +++ b/src/globals/config/schema.ts @@ -4,6 +4,13 @@ import fieldSchema from '../../fields/config/schema'; const schema = joi.object().keys({ slug: joi.string().required(), label: joi.string(), + hooks: joi.object({ + beforeValidate: joi.array().items(joi.func()), + beforeChange: joi.array().items(joi.func()), + afterChange: joi.array().items(joi.func()), + beforeRead: joi.array().items(joi.func()), + afterRead: joi.array().items(joi.func()), + }), access: joi.object({ read: joi.func(), update: joi.func(), diff --git a/src/globals/config/types.ts b/src/globals/config/types.ts index 427f34640d..bb848db4dd 100644 --- a/src/globals/config/types.ts +++ b/src/globals/config/types.ts @@ -1,20 +1,55 @@ import React from 'react'; import { Model, Document } from 'mongoose'; import { DeepRequired } from 'ts-essentials'; +import { PayloadRequest } from '../../express/types'; import { Access } from '../../config/types'; import { Field } from '../../fields/config/types'; +export type BeforeValidateHook = (args?: { + data?: any; + req?: PayloadRequest; + originalDoc?: any; +}) => any; + +export type BeforeChangeHook = (args?: { + data: any; + req: PayloadRequest; + originalDoc?: any; +}) => any; + +export type AfterChangeHook = (args?: { + doc: any; + req: PayloadRequest; +}) => any; + +export type BeforeReadHook = (args?: { + doc: any; + req: PayloadRequest; + query: { [key: string]: any }; +}) => any; + +export type AfterReadHook = (args?: { + doc: any; + req: PayloadRequest; + query?: { [key: string]: any }; +}) => any; + export type GlobalModel = Model export type PayloadGlobalConfig = { slug: string label?: string preview?: (doc: Document, token: string) => string + hooks?: { + beforeValidate?: BeforeValidateHook[] + beforeChange?: BeforeChangeHook[] + afterChange?: AfterChangeHook[] + beforeRead?: BeforeReadHook[] + afterRead?: AfterReadHook[] + } access?: { - create?: Access; read?: Access; update?: Access; - delete?: Access; admin?: Access; } fields: Field[]; diff --git a/src/graphql/schema/buildMutationInputType.ts b/src/graphql/schema/buildMutationInputType.ts index a3114a75ae..dcac4e658e 100644 --- a/src/graphql/schema/buildMutationInputType.ts +++ b/src/graphql/schema/buildMutationInputType.ts @@ -15,6 +15,7 @@ import withNullableType from './withNullableType'; import formatName from '../utilities/formatName'; import combineParentName from '../utilities/combineParentName'; import { ArrayField, Field, FieldWithSubFields, GroupField, RelationshipField, RowField, SelectField } from '../../fields/config/types'; +import { toWords } from '../../utilities/formatLabels'; function buildMutationInputType(name: string, fields: Field[], parentName: string, forceNullable = false): GraphQLInputObjectType { const fieldToSchemaMap = { @@ -68,7 +69,7 @@ function buildMutationInputType(name: string, fields: Field[], parentName: strin let type: PayloadGraphQLRelationshipType = GraphQLString; if (Array.isArray(relationTo)) { - const fullName = `${combineParentName(parentName, field.label)}RelationshipInput`; + const fullName = `${combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label)}RelationshipInput`; type = new GraphQLInputObjectType({ name: fullName, fields: { @@ -91,14 +92,14 @@ function buildMutationInputType(name: string, fields: Field[], parentName: strin return { type: field.hasMany ? new GraphQLList(type) : type }; }, array: (field: ArrayField) => { - const fullName = combineParentName(parentName, field.label); + const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label); let type: GraphQLType | GraphQLList = buildMutationInputType(fullName, field.fields, fullName); type = new GraphQLList(withNullableType(field, type, forceNullable)); return { type }; }, group: (field: GroupField) => { const requiresAtLeastOneField = field.fields.some((subField) => (subField.required && !subField.localized)); - const fullName = combineParentName(parentName, field.label); + const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label); let type: GraphQLType = buildMutationInputType(fullName, field.fields, fullName); if (requiresAtLeastOneField) type = new GraphQLNonNull(type); return { type }; diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index 96185cc47d..f39786b5a7 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -13,11 +13,12 @@ import { GraphQLUnionType, } from 'graphql'; import { DateTimeResolver, EmailAddressResolver } from 'graphql-scalars'; -import { Field, RadioField, RelationshipField, SelectField, UploadField, optionIsObject } from '../../fields/config/types'; +import { Field, RadioField, RelationshipField, SelectField, UploadField, optionIsObject, ArrayField, GroupField, BlockField, RowField } from '../../fields/config/types'; import formatName from '../utilities/formatName'; import combineParentName from '../utilities/combineParentName'; import withNullableType from './withNullableType'; import { BaseFields } from '../../collections/graphql/types'; +import { toWords } from '../../utilities/formatLabels'; type LocaleInputType = { locale: { @@ -44,7 +45,8 @@ function buildObjectType(name: string, fields: Field[], parentName: string, base date: (field: Field) => ({ type: withNullableType(field, DateTimeResolver) }), upload: (field: UploadField) => { const { relationTo, label } = field; - const uploadName = combineParentName(parentName, label); + + const uploadName = combineParentName(parentName, label === false ? toWords(field.name, true) : label); // If the relationshipType is undefined at this point, // it can be assumed that this blockType can have a relationship @@ -186,7 +188,7 @@ function buildObjectType(name: string, fields: Field[], parentName: string, base const { relationTo, label } = field; const isRelatedToManyCollections = Array.isArray(relationTo); const hasManyValues = field.hasMany; - const relationshipName = combineParentName(parentName, label); + const relationshipName = combineParentName(parentName, label === false ? toWords(field.name, true) : label); let type; let relationToType = null; @@ -406,15 +408,15 @@ function buildObjectType(name: string, fields: Field[], parentName: string, base return relationship; }, - array: (field) => { - const fullName = combineParentName(parentName, field.label); + array: (field: ArrayField) => { + const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label); let type = recursiveBuildObjectType(fullName, field.fields, fullName); type = new GraphQLList(withNullableType(field, type)); return { type }; }, - group: (field) => { - const fullName = combineParentName(parentName, field.label); + group: (field: GroupField) => { + const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label); const type = recursiveBuildObjectType(fullName, field.fields, fullName); return { type }; @@ -425,8 +427,10 @@ function buildObjectType(name: string, fields: Field[], parentName: string, base return this.types.blockTypes[block.slug]; }); + const fullName = combineParentName(parentName, field.label === false ? toWords(field.name, true) : field.label); + const type = new GraphQLList(new GraphQLUnionType({ - name: combineParentName(parentName, field.label), + name: fullName, types: blockTypes, resolveType: (data) => this.types.blockTypes[data.blockType].name, })); diff --git a/src/utilities/formatLabels.spec.js b/src/utilities/formatLabels.spec.ts similarity index 63% rename from src/utilities/formatLabels.spec.js rename to src/utilities/formatLabels.spec.ts index 587958df55..fb57937183 100644 --- a/src/utilities/formatLabels.spec.js +++ b/src/utilities/formatLabels.spec.ts @@ -1,4 +1,4 @@ -import formatLabels from './formatLabels'; +import { formatLabels, toWords } from './formatLabels'; describe('formatLabels', () => { it('should format singular slug', () => { @@ -28,4 +28,14 @@ describe('formatLabels', () => { plural: 'Camel Case Items', }); }); + + describe('toWords', () => { + it('should convert camel to capitalized words', () => { + expect(toWords('camelCaseItems')).toBe('Camel Case Items'); + }); + + it('should allow no separator (used for building GraphQL label from name)', () => { + expect(toWords('myGraphField', true)).toBe('MyGraphField'); + }); + }); }); diff --git a/src/utilities/formatLabels.ts b/src/utilities/formatLabels.ts index 4512d1abf0..16b5cf7e87 100644 --- a/src/utilities/formatLabels.ts +++ b/src/utilities/formatLabels.ts @@ -1,8 +1,8 @@ import pluralize, { isPlural, singular } from 'pluralize'; -const capitalizeFirstLetter = (string: string) => string.charAt(0).toUpperCase() + string.slice(1); +const capitalizeFirstLetter = (string: string): string => string.charAt(0).toUpperCase() + string.slice(1); -const toWords = (inputString: string): string => { +const toWords = (inputString: string, joinWords = false): string => { const notNullString = inputString || ''; const trimmedString = notNullString.trim(); const arrayOfStrings = trimmedString.split(/[\s-]/); @@ -15,10 +15,12 @@ const toWords = (inputString: string): string => { } }); - return splitStringsArray.join(' '); + return joinWords + ? splitStringsArray.join('').replace(/\s/gi, '') + : splitStringsArray.join(' '); }; -const formatLabels = ((slug: string): { singular: string, plural: string} => { +const formatLabels = ((slug: string): { singular: string, plural: string } => { const words = toWords(slug); return (isPlural(slug)) ? { @@ -31,4 +33,7 @@ const formatLabels = ((slug: string): { singular: string, plural: string} => { }; }); -export default formatLabels; +export { + formatLabels, + toWords, +};