diff --git a/components/fields/Json.ts b/components/fields/Json.ts new file mode 100644 index 000000000..f140a9ba7 --- /dev/null +++ b/components/fields/Json.ts @@ -0,0 +1 @@ +export type { Props } from '../../dist/admin/components/forms/field-types/JSON/types'; diff --git a/package.json b/package.json index 35d64868f..70d5932e0 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "@faceless-ui/modal": "^2.0.1", "@faceless-ui/scroll-info": "^1.2.3", "@faceless-ui/window-info": "^2.0.2", + "@monaco-editor/react": "^4.4.6", "@types/is-plain-object": "^2.0.4", "@types/sharp": "^0.26.1", "babel-jest": "^26.3.0", diff --git a/src/admin/components/elements/WhereBuilder/field-types.tsx b/src/admin/components/elements/WhereBuilder/field-types.tsx index 170e18d6f..25af2dc35 100644 --- a/src/admin/components/elements/WhereBuilder/field-types.tsx +++ b/src/admin/components/elements/WhereBuilder/field-types.tsx @@ -84,6 +84,10 @@ const fieldTypeConditions = { component: 'Text', operators: [...base, like, contains], }, + json: { + component: 'Text', + operators: [...base, like, contains], + }, richText: { component: 'Text', operators: [...base, like, contains], diff --git a/src/admin/components/forms/field-types/JSON/JSON.tsx b/src/admin/components/forms/field-types/JSON/JSON.tsx new file mode 100644 index 000000000..501c48eb8 --- /dev/null +++ b/src/admin/components/forms/field-types/JSON/JSON.tsx @@ -0,0 +1,90 @@ + +import React, { useCallback } from 'react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import Editor from '@monaco-editor/react'; +import useField from '../../useField'; +import withCondition from '../../withCondition'; +import Label from '../../Label'; +import Error from '../../Error'; +import FieldDescription from '../../FieldDescription'; +import { json } from '../../../../../fields/validations'; +import { Props } from './types'; + +import './index.scss'; + +const baseClass = 'json-field'; + +const JSONField: React.FC = (props) => { + const { + path: pathFromProps, + name, + required, + validate = json, + admin: { + readOnly, + style, + className, + width, + description, + condition, + } = {}, + label, + } = props; + + const path = pathFromProps || name; + + const memoizedValidate = useCallback((value, options) => { + return validate(value, { ...options, required }); + }, [validate, required]); + + const { + value, + showError, + setValue, + errorMessage, + } = useField({ + path, + validate: memoizedValidate, + condition, + }); + + const classes = [ + baseClass, + 'field-type', + className, + showError && 'error', + readOnly && 'read-only', + ].filter(Boolean).join(' '); + + return ( +
+ +
+ ); +}; + +export default withCondition(JSONField); diff --git a/src/admin/components/forms/field-types/JSON/index.scss b/src/admin/components/forms/field-types/JSON/index.scss new file mode 100644 index 000000000..b211e930a --- /dev/null +++ b/src/admin/components/forms/field-types/JSON/index.scss @@ -0,0 +1,12 @@ +@import '../../../../scss/styles.scss'; + +.json-field { + position: relative; + margin-bottom: $baseline; + + &.error { + textarea { + border: 1px solid var(--theme-error-500) !important; + } + } +} diff --git a/src/admin/components/forms/field-types/JSON/index.tsx b/src/admin/components/forms/field-types/JSON/index.tsx new file mode 100644 index 000000000..b707c411e --- /dev/null +++ b/src/admin/components/forms/field-types/JSON/index.tsx @@ -0,0 +1,13 @@ +import React, { Suspense, lazy } from 'react'; +import Loading from '../../../elements/Loading'; +import { Props } from './types'; + +const JSON = lazy(() => import('./JSON')); + +const JSONField: React.FC = (props) => ( + }> + + +); + +export default JSONField; diff --git a/src/admin/components/forms/field-types/JSON/types.ts b/src/admin/components/forms/field-types/JSON/types.ts new file mode 100644 index 000000000..80820cd23 --- /dev/null +++ b/src/admin/components/forms/field-types/JSON/types.ts @@ -0,0 +1,5 @@ +import { JSONField } from '../../../../../fields/config/types'; + +export type Props = Omit & { + path?: string +} diff --git a/src/admin/components/forms/field-types/index.tsx b/src/admin/components/forms/field-types/index.tsx index 2ff0ca6eb..0e1923a97 100644 --- a/src/admin/components/forms/field-types/index.tsx +++ b/src/admin/components/forms/field-types/index.tsx @@ -1,4 +1,5 @@ import code from './Code'; +import json from './JSON'; import email from './Email'; import hidden from './HiddenInput'; import text from './Text'; @@ -26,6 +27,7 @@ import ui from './UI'; export type FieldTypes = { code: React.ComponentType + json: React.ComponentType email: React.ComponentType hidden: React.ComponentType text: React.ComponentType @@ -52,6 +54,7 @@ export type FieldTypes = { const fieldTypes: FieldTypes = { code, + json, email, hidden, text, diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx index fdd5e2251..88394a87b 100644 --- a/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx @@ -11,6 +11,7 @@ export default { number: Text, email: Text, code: Text, + json: Text, checkbox: Text, radio: Select, row: Nested, diff --git a/src/admin/components/views/collections/List/Cell/field-types/JSON/index.scss b/src/admin/components/views/collections/List/Cell/field-types/JSON/index.scss new file mode 100644 index 000000000..4b8449827 --- /dev/null +++ b/src/admin/components/views/collections/List/Cell/field-types/JSON/index.scss @@ -0,0 +1,17 @@ +@import '../../../../../../../scss/styles.scss'; + +.json-cell { + font-size: 1rem; + line-height: base(1); + border: 0; + display: inline-flex; + vertical-align: middle; + background: var(--theme-elevation-150); + color: var(--theme-elevation-800); + border-radius: $style-radius-s; + padding: 0 base(0.25); + padding-left: base(0.0875 + 0.25); + + background: var(--theme-elevation-100); + color: var(--theme-elevation-800); +} diff --git a/src/admin/components/views/collections/List/Cell/field-types/JSON/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/JSON/index.tsx new file mode 100644 index 000000000..1d8cd9ec2 --- /dev/null +++ b/src/admin/components/views/collections/List/Cell/field-types/JSON/index.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import './index.scss'; + +const JSONCell = ({ data }) => { + const textToShow = data.length > 100 ? `${data.substring(0, 100)}\u2026` : data; + + return ( + + + {JSON.stringify(textToShow)} + + + ); +}; + +export default JSONCell; diff --git a/src/admin/components/views/collections/List/Cell/field-types/index.tsx b/src/admin/components/views/collections/List/Cell/field-types/index.tsx index ad3f4efa5..de0978791 100644 --- a/src/admin/components/views/collections/List/Cell/field-types/index.tsx +++ b/src/admin/components/views/collections/List/Cell/field-types/index.tsx @@ -3,6 +3,7 @@ import blocks from './Blocks'; import checkbox from './Checkbox'; import code from './Code'; import date from './Date'; +import json from './JSON'; import relationship from './Relationship'; import richText from './Richtext'; import select from './Select'; @@ -15,6 +16,7 @@ export default { code, checkbox, date, + json, relationship, richText, select, diff --git a/src/bin/generateTypes.ts b/src/bin/generateTypes.ts index 4ee3e6a15..8dc14fde7 100644 --- a/src/bin/generateTypes.ts +++ b/src/bin/generateTypes.ts @@ -74,6 +74,11 @@ function generateFieldTypes(config: SanitizedConfig, fields: Field[]): { break; } + case 'json': { + fieldSchema = { type: 'object' }; + break; + } + case 'richText': { fieldSchema = { type: 'array', diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index fcdb0d11f..5b00fe823 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -136,6 +136,15 @@ export const code = baseField.keys({ }), }); +export const json = baseField.keys({ + type: joi.string().valid('json').required(), + name: joi.string().required(), + defaultValue: joi.alternatives().try( + joi.array(), + joi.object(), + ), +}); + export const select = baseField.keys({ type: joi.string().valid('select').required(), name: joi.string().required(), @@ -448,6 +457,7 @@ const fieldSchema = joi.alternatives() textarea, email, code, + json, select, group, array, diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 7afe5362d..ed9c59f39 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -259,6 +259,11 @@ export type CodeField = Omit & { type: 'code'; } +export type JSONField = Omit & { + admin?: Admin + type: 'json'; +} + export type SelectField = FieldBase & { type: 'select' options: Option[] @@ -399,6 +404,7 @@ export type Field = | SelectField | UploadField | CodeField + | JSONField | PointField | RowField | CollapsibleField @@ -421,6 +427,7 @@ export type FieldAffectingData = | SelectField | UploadField | CodeField + | JSONField | PointField | TabAsField @@ -440,6 +447,7 @@ export type NonPresentationalField = | SelectField | UploadField | CodeField + | JSONField | PointField | RowField | TabsField @@ -467,7 +475,7 @@ export type FieldWithMaxDepth = | RelationshipField export function fieldHasSubFields(field: Field): field is FieldWithSubFields { - return (field.type === 'group' || field.type === 'array' || field.type === 'row' || field.type === 'collapsible'); + return (field.type === 'group' || field.type === 'array' || field.type === 'row' || field.type === 'collapsible' || field.type === 'relationship'); } export function fieldIsArrayType(field: Field): field is ArrayField { diff --git a/src/fields/sortableFieldTypes.ts b/src/fields/sortableFieldTypes.ts index 224b0c639..ad9a274bb 100644 --- a/src/fields/sortableFieldTypes.ts +++ b/src/fields/sortableFieldTypes.ts @@ -2,6 +2,7 @@ export default [ 'text', 'textarea', 'code', + 'json', 'number', 'email', 'radio', diff --git a/src/fields/validations.ts b/src/fields/validations.ts index 5469feb30..15ca103bb 100644 --- a/src/fields/validations.ts +++ b/src/fields/validations.ts @@ -6,6 +6,7 @@ import { CodeField, DateField, EmailField, + JSONField, NumberField, PointField, RadioField, @@ -132,6 +133,23 @@ export const code: Validate = (value: string, { t, return true; }; +export const json: Validate = (value: string, { + t, required, +}) => { + if (required && !value) { + return t('validation:required'); + } + + try { + const incomingJSON = typeof value === 'object' ? JSON.stringify(value) : value; + const validJSON = JSON.parse(incomingJSON); + } catch (err) { + return `Invalid JSON: ${err}`; + } + + return true; +}; + export const richText: Validate = (value, { t, required }) => { if (required) { const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue); @@ -398,4 +416,5 @@ export default { radio, blocks, point, + json, }; diff --git a/src/graphql/schema/buildMutationInputType.ts b/src/graphql/schema/buildMutationInputType.ts index 8faddc44c..0036780d2 100644 --- a/src/graphql/schema/buildMutationInputType.ts +++ b/src/graphql/schema/buildMutationInputType.ts @@ -16,7 +16,7 @@ import { GraphQLJSON } from 'graphql-type-json'; import withNullableType from './withNullableType'; import formatName from '../utilities/formatName'; import combineParentName from '../utilities/combineParentName'; -import { ArrayField, CodeField, DateField, EmailField, Field, fieldAffectsData, GroupField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TextareaField, TextField, UploadField, CollapsibleField, TabsField, CheckboxField, BlockField, tabHasName } from '../../fields/config/types'; +import { ArrayField, CodeField, JSONField, DateField, EmailField, Field, fieldAffectsData, GroupField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TextareaField, TextField, UploadField, CollapsibleField, TabsField, CheckboxField, BlockField, tabHasName } from '../../fields/config/types'; import { toWords } from '../../utilities/formatLabels'; import { Payload } from '../../index'; import { SanitizedCollectionConfig } from '../../collections/config/types'; @@ -66,6 +66,10 @@ function buildMutationInputType(payload: Payload, name: string, fields: Field[], ...inputObjectTypeConfig, [field.name]: { type: withNullableType(field, GraphQLString, forceNullable) }, }), + json: (inputObjectTypeConfig: InputObjectTypeConfig, field: JSONField) => ({ + ...inputObjectTypeConfig, + [field.name]: { type: withNullableType(field, GraphQLJSON, forceNullable) }, + }), date: (inputObjectTypeConfig: InputObjectTypeConfig, field: DateField) => ({ ...inputObjectTypeConfig, [field.name]: { type: withNullableType(field, GraphQLString, forceNullable) }, diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index 17eeac6fe..f7addc253 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -30,6 +30,7 @@ import { EmailField, TextareaField, CodeField, + JSONField, DateField, PointField, CheckboxField, @@ -104,6 +105,10 @@ function buildObjectType({ ...objectTypeConfig, [field.name]: { type: withNullableType(field, GraphQLString, forceNullable) }, }), + json: (objectTypeConfig: ObjectTypeConfig, field: JSONField) => ({ + ...objectTypeConfig, + [field.name]: { type: withNullableType(field, GraphQLJSON, forceNullable) }, + }), date: (objectTypeConfig: ObjectTypeConfig, field: DateField) => ({ ...objectTypeConfig, [field.name]: { type: withNullableType(field, DateTimeResolver, forceNullable) }, diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index c434a6dc1..70c56e80f 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -18,6 +18,7 @@ import { fieldAffectsData, fieldIsLocalized, fieldIsPresentationalOnly, GroupField, + JSONField, NonPresentationalField, NumberField, PointField, @@ -149,6 +150,13 @@ const fieldToSchemaMap: Record = { [field.name]: localizeSchema(field, baseSchema, config.localization), }); }, + json: (field: JSONField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { + const baseSchema = { ...formatBaseSchema(field, buildSchemaOptions), type: Schema.Types.Mixed }; + + schema.add({ + [field.name]: localizeSchema(field, baseSchema, config.localization), + }); + }, point: (field: PointField, schema: Schema, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): void => { const baseSchema: SchemaTypeOptions = { type: { diff --git a/test/fields/collections/JSON/index.tsx b/test/fields/collections/JSON/index.tsx new file mode 100644 index 000000000..a6a9199b6 --- /dev/null +++ b/test/fields/collections/JSON/index.tsx @@ -0,0 +1,36 @@ +import type { CollectionConfig } from '../../../../src/collections/config/types'; + + +type JSONField = { + id: string; + json?: any; + createdAt: string; + updatedAt: string; +} + +const JSON: CollectionConfig = { + slug: 'json-fields', + fields: [ + { + name: 'json', + type: 'json', + // maxLength: 100, + }, + ], +}; + +export const jsonDoc: Partial = { + json: { + property: 'value', + arr: [ + 'val1', + 'val2', + 'val3', + ], + nested: { + value: 'nested value', + }, + }, +}; + +export default JSON; diff --git a/test/fields/config.ts b/test/fields/config.ts index 4dbdd6e82..eea4eb6c2 100644 --- a/test/fields/config.ts +++ b/test/fields/config.ts @@ -19,6 +19,7 @@ import Uploads, { uploadsDoc } from './collections/Upload'; import IndexedFields from './collections/Indexed'; import NumberFields, { numberDoc } from './collections/Number'; import CodeFields, { codeDoc } from './collections/Code'; +import JSONFields, { jsonDoc } from './collections/JSON'; import RelationshipFields from './collections/Relationship'; import RadioFields, { radiosDoc } from './collections/Radio'; @@ -45,6 +46,7 @@ export default buildConfig({ RadioFields, GroupFields, IndexedFields, + JSONFields, NumberFields, PointFields, RelationshipFields, @@ -77,6 +79,7 @@ export default buildConfig({ await payload.create({ collection: 'point-fields', data: pointDoc }); await payload.create({ collection: 'date-fields', data: dateDoc }); await payload.create({ collection: 'code-fields', data: codeDoc }); + await payload.create({ collection: 'json-fields', data: jsonDoc }); const createdTextDoc = await payload.create({ collection: textFieldsSlug, data: textDoc });