feat: autolabel fields when label is omitted (#42)

* feat: autolabel fields when omitted

* feat: handle autolabel in graphql mutation build

* feat: autolabel blocks

* test: add required slug field to blocks

* feat: handle graphql names when label is false

* feat: adds relationship field to test searchable input

* feat: handle block cell type labeling pluralization

* docs: remove all explicit labeling, no longer needed

* fix: falsey column labels, allows false array labels

* fix: client tests

* fix: auto-labels globals

* docs: globals auto-labeling and hooks clarification

* fix; proper object type naming

Co-authored-by: James <james@trbl.design>
This commit is contained in:
Elliot DeNolf
2021-04-16 22:37:08 -04:00
committed by GitHub
parent 21b2bd4b67
commit b383eb65c6
49 changed files with 277 additions and 150 deletions

View File

@@ -69,7 +69,7 @@ const ColumnSelector: React.FC<Props> = (props) => {
pillStyle={isEnabled ? 'dark' : undefined}
className={`${baseClass}__active-column`}
>
{field.label}
{field.label || field.name}
</Pill>
);
})}

View File

@@ -1,5 +1,5 @@
export type Props = {
label?: string | JSX.Element
label?: string | false | JSX.Element
required?: boolean
htmlFor?: string
}

View File

@@ -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}

View File

@@ -7,6 +7,7 @@ export type Props = Omit<ArrayField, 'type'> & {
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

View File

@@ -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

View File

@@ -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(<BlocksCell data={data} field={field} />);
const { container } = render(<BlocksCell
data={data}
field={field}
/>);
const el = container.querySelector('span');
expect(el).toHaveTextContent('2 Blocks Content - Number, Number');
});
it('renders zero', () => {
const data = [];
const { container } = render(<BlocksCell data={data} field={field} />);
const { container } = render(<BlocksCell
data={data}
field={field}
/>);
const el = container.querySelector('span');
expect(el).toHaveTextContent('0 Blocks Content');
});
@@ -52,7 +58,10 @@ describe('Cell Types', () => {
{ blockType: 'number' },
];
const { container } = render(<BlocksCell data={data} field={field} />);
const { container } = render(<BlocksCell
data={data}
field={field}
/>);
const el = container.querySelector('span');
expect(el).toHaveTextContent('6 Blocks Content - Number, Number, Number, Number, Number and 1 more');
});

View File

@@ -1,8 +1,14 @@
import React from 'react';
import { ArrayField } from '../../../../../../../../fields/config/types';
const ArrayCell = ({ data, field }) => {
type Props = {
data: Record<string, unknown>
field: ArrayField
}
const ArrayCell: React.FC<Props> = ({ data, field }) => {
const arrayFields = data ?? [];
const label = `${arrayFields.length} ${field.label} rows`;
const label = `${arrayFields.length} ${field?.labels?.plural || 'Rows'}`;
return (
<span>{label}</span>

View File

@@ -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 (

View File

@@ -36,7 +36,7 @@ const DefaultCell: React.FC<Props> = (props) => {
if (!CellComponent) {
return (
<WrapElement {...wrapElementProps}>
{(cellData === '' || typeof cellData === 'undefined') && `<No ${field.label ?? 'data'}>`}
{(cellData === '' || typeof cellData === 'undefined') && `<No ${typeof field.label === 'string' ? field.label : 'data'}>`}
{typeof cellData === 'string' && cellData}
{typeof cellData === 'number' && cellData}
{typeof cellData === 'object' && JSON.stringify(cellData)}

View File

@@ -51,7 +51,7 @@ const buildColumns = (collection: CollectionConfig, columns: string[], setSort:
components: {
Heading: (
<SortColumn
label={field.label}
label={field.label || field.name}
name={field.name}
handleChange={setSort}
disable={field.disableSort || undefined}

View File

@@ -8,7 +8,7 @@ import baseVerificationFields from '../../fields/baseFields/baseVerificationFiel
import baseAccountLockFields from '../../fields/baseFields/baseAccountLockFields';
import baseUploadFields from '../../fields/baseFields/baseUploadFields';
import baseImageUploadFields from '../../fields/baseFields/baseImageUploadFields';
import formatLabels from '../../utilities/formatLabels';
import { formatLabels } from '../../utilities/formatLabels';
import { defaults, authDefaults } from './defaults';
const mergeBaseFields = (fields, baseFields) => {
@@ -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 = {};

View File

@@ -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;

View File

@@ -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';

View File

@@ -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);

View File

@@ -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;
});

View File

@@ -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),

View File

@@ -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

View File

@@ -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;

View File

@@ -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(),

View File

@@ -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<Document>
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[];

View File

@@ -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<GraphQLType> = 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 };

View File

@@ -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,
}));

View File

@@ -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');
});
});
});

View File

@@ -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,
};