From 98676bea69a37c6521dea1dfafa4c4a9967efc1e Mon Sep 17 00:00:00 2001 From: James Date: Wed, 13 Jul 2022 14:45:10 -0700 Subject: [PATCH] feat: adds collapsible field type --- .../elements/Collapsible/index.scss | 49 +++++++++++ .../components/elements/Collapsible/index.tsx | 46 ++++++++++- .../elements/Collapsible/provider.tsx | 17 ++++ .../components/elements/Collapsible/types.ts | 3 + .../forms/DraggableSection/index.scss | 13 --- .../forms/field-types/Collapsible/index.scss | 11 +++ .../forms/field-types/Collapsible/index.tsx | 49 +++++++++++ .../forms/field-types/Collapsible/types.ts | 9 ++ .../components/forms/field-types/index.tsx | 3 + .../RenderFieldsToDiff/fields/index.tsx | 1 + src/admin/scss/app.scss | 2 +- src/fields/config/schema.ts | 11 +++ src/fields/config/types.ts | 20 +++-- src/fields/hooks/afterChange/promise.ts | 3 +- src/fields/hooks/afterRead/promise.ts | 3 +- src/fields/hooks/beforeChange/promise.ts | 3 +- src/fields/hooks/beforeValidate/promise.ts | 3 +- src/graphql/schema/buildMutationInputType.ts | 16 +++- src/graphql/schema/buildObjectType.ts | 12 +++ .../schema/fieldToWhereInputSchemaMap.ts | 41 ++++++++-- src/mongoose/buildSchema.ts | 26 ++++-- test/e2e/fields/config.ts | 82 ++++++++++++++++++- test/e2e/fields/shared.ts | 42 ++++++++++ 23 files changed, 421 insertions(+), 44 deletions(-) create mode 100644 src/admin/components/elements/Collapsible/provider.tsx create mode 100644 src/admin/components/forms/field-types/Collapsible/index.scss create mode 100644 src/admin/components/forms/field-types/Collapsible/index.tsx create mode 100644 src/admin/components/forms/field-types/Collapsible/types.ts diff --git a/src/admin/components/elements/Collapsible/index.scss b/src/admin/components/elements/Collapsible/index.scss index e69de29bb..a08bc80f6 100644 --- a/src/admin/components/elements/Collapsible/index.scss +++ b/src/admin/components/elements/Collapsible/index.scss @@ -0,0 +1,49 @@ +@import '../../../scss/styles.scss'; + +.collapsible { + border: 1px solid var(--theme-elevation-200); + border-radius: $style-radius-m; + + &:hover { + border: 1px solid var(--theme-elevation-300); + } + + header { + position: relative; + background: var(--theme-elevation-100); + border-top-right-radius: $style-radius-s; + border-top-left-radius: $style-radius-s; + display: flex; + padding: base(.75) base(1); + } + + &--collapsed { + header { + border-bottom-right-radius: $style-radius-s; + border-bottom-left-radius: $style-radius-s; + } + } + + &__toggle { + margin: 0 0 0 auto; + transform: rotate(.5turn); + + .btn__icon { + background-color: var(--theme-elevation-0); + } + + &--collapsed { + transform: rotate(0turn); + } + } + + &__content { + padding: $baseline; + } + + @include small-break { + header { + padding: base(.75) var(--gutter-h); + } + } +} diff --git a/src/admin/components/elements/Collapsible/index.tsx b/src/admin/components/elements/Collapsible/index.tsx index d69a3bf2d..eb76ba0f0 100644 --- a/src/admin/components/elements/Collapsible/index.tsx +++ b/src/admin/components/elements/Collapsible/index.tsx @@ -1,14 +1,52 @@ -import React from 'react'; +import React, { useState } from 'react'; +import AnimateHeight from 'react-animate-height'; +import Button from '../Button'; import { Props } from './types'; +import { CollapsibleProvider, useCollapsible } from './provider'; import './index.scss'; const baseClass = 'collapsible'; -export const Collapsible: React.FC = ({ children }) => { +export const Collapsible: React.FC = ({ children, onToggle, className, header }) => { + const [collapsed, setCollapsed] = useState(false); + const isNested = useCollapsible(); + return ( -
- {children} +
+ +
+ {header && ( +
+ {header} +
+ )} +
+ +
+ {children} +
+
+
); }; diff --git a/src/admin/components/elements/Collapsible/provider.tsx b/src/admin/components/elements/Collapsible/provider.tsx new file mode 100644 index 000000000..a7ec1db64 --- /dev/null +++ b/src/admin/components/elements/Collapsible/provider.tsx @@ -0,0 +1,17 @@ +import React, { + createContext, useContext, +} from 'react'; + +const Context = createContext(false); + +export const CollapsibleProvider: React.FC<{ children?: React.ReactNode, withinCollapsible: boolean }> = ({ children, withinCollapsible }) => { + return ( + + {children} + + ); +}; + +export const useCollapsible = (): boolean => useContext(Context); + +export default Context; diff --git a/src/admin/components/elements/Collapsible/types.ts b/src/admin/components/elements/Collapsible/types.ts index 2b19c46e5..da3f3b0f0 100644 --- a/src/admin/components/elements/Collapsible/types.ts +++ b/src/admin/components/elements/Collapsible/types.ts @@ -1,5 +1,8 @@ import React from 'react'; export type Props = { + className?: string + header?: React.ReactNode children: React.ReactNode + onToggle?: (collapsed: boolean) => void } diff --git a/src/admin/components/forms/DraggableSection/index.scss b/src/admin/components/forms/DraggableSection/index.scss index b65d0d8a6..66002685f 100644 --- a/src/admin/components/forms/DraggableSection/index.scss +++ b/src/admin/components/forms/DraggableSection/index.scss @@ -26,19 +26,6 @@ margin-left: - base(.75); margin-right: - base(.75); width: calc(100% + #{base(1.5)}); - - .toggle-collapse { - margin: 0 0 0 auto; - transform: rotate(.5turn); - - .btn__icon { - background-color: var(--theme-elevation-0); - } - - &--is-collapsed { - transform: rotate(0turn); - } - } } &__render-fields-wrapper { diff --git a/src/admin/components/forms/field-types/Collapsible/index.scss b/src/admin/components/forms/field-types/Collapsible/index.scss new file mode 100644 index 000000000..c093b2a21 --- /dev/null +++ b/src/admin/components/forms/field-types/Collapsible/index.scss @@ -0,0 +1,11 @@ +@import '../../../../scss/styles.scss'; + +.collapsible-field { + &__label { + font-weight: 600; + } + + .field-type:last-child { + margin-bottom: 0; + } +} diff --git a/src/admin/components/forms/field-types/Collapsible/index.tsx b/src/admin/components/forms/field-types/Collapsible/index.tsx new file mode 100644 index 000000000..a5cb47536 --- /dev/null +++ b/src/admin/components/forms/field-types/Collapsible/index.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import RenderFields from '../../RenderFields'; +import withCondition from '../../withCondition'; +import { Props } from './types'; +import { fieldAffectsData } from '../../../../../fields/config/types'; +import { Collapsible } from '../../../elements/Collapsible'; + +import './index.scss'; + +const baseClass = 'collapsible-field'; + +const CollapsibleField: React.FC = (props) => { + const { + label, + fields, + fieldTypes, + path, + permissions, + admin: { + readOnly, + className, + }, + } = props; + + const classes = [ + 'field-type', + baseClass, + className, + ].filter(Boolean).join(' '); + + return ( + {label}
} + > + ({ + ...field, + path: `${path ? `${path}.` : ''}${fieldAffectsData(field) ? field.name : ''}`, + }))} + /> + + ); +}; + +export default withCondition(CollapsibleField); diff --git a/src/admin/components/forms/field-types/Collapsible/types.ts b/src/admin/components/forms/field-types/Collapsible/types.ts new file mode 100644 index 000000000..96269341a --- /dev/null +++ b/src/admin/components/forms/field-types/Collapsible/types.ts @@ -0,0 +1,9 @@ +import { CollapsibleField } from '../../../../../fields/config/types'; +import { FieldTypes } from '..'; +import { FieldPermissions } from '../../../../../auth/types'; + +export type Props = Omit & { + path?: string + fieldTypes: FieldTypes + permissions: FieldPermissions +} diff --git a/src/admin/components/forms/field-types/index.tsx b/src/admin/components/forms/field-types/index.tsx index 6a8783888..a86a9c087 100644 --- a/src/admin/components/forms/field-types/index.tsx +++ b/src/admin/components/forms/field-types/index.tsx @@ -19,6 +19,7 @@ import blocks from './Blocks'; import group from './Group'; import array from './Array'; import row from './Row'; +import collapsible from './Collapsible'; import upload from './Upload'; import ui from './UI'; @@ -42,6 +43,7 @@ export type FieldTypes = { group: React.ComponentType array: React.ComponentType row: React.ComponentType + collapsible: React.ComponentType upload: React.ComponentType ui: React.ComponentType } @@ -66,6 +68,7 @@ const fieldTypes: FieldTypes = { group, array, row, + collapsible, upload, ui, }; diff --git a/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx b/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx index 2628c7e07..8da38dfe1 100644 --- a/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx +++ b/src/admin/components/views/Version/RenderFieldsToDiff/fields/index.tsx @@ -12,6 +12,7 @@ export default { checkbox: Text, radio: Text, row: Nested, + collapsible: Nested, group: Nested, array: Iterable, blocks: Iterable, diff --git a/src/admin/scss/app.scss b/src/admin/scss/app.scss index dd6d1ba06..78db38d0c 100644 --- a/src/admin/scss/app.scss +++ b/src/admin/scss/app.scss @@ -133,7 +133,7 @@ } @include small-break { - --gutter-h: #{base(1)}; + --gutter-h: #{base(.75)}; } } diff --git a/src/fields/config/schema.ts b/src/fields/config/schema.ts index c9c6eb713..26e2baf77 100644 --- a/src/fields/config/schema.ts +++ b/src/fields/config/schema.ts @@ -175,6 +175,16 @@ export const row = baseField.keys({ }), }); +export const collapsible = baseField.keys({ + label: joi.string().required(), + type: joi.string().valid('collapsible').required(), + fields: joi.array().items(joi.link('#field')), + admin: baseAdminFields.keys({ + readOnly: joi.forbidden(), + hidden: joi.forbidden(), + }), +}); + export const group = baseField.keys({ type: joi.string().valid('group').required(), name: joi.string().required(), @@ -367,6 +377,7 @@ const fieldSchema = joi.alternatives() group, array, row, + collapsible, radio, relationship, checkbox, diff --git a/src/fields/config/types.ts b/src/fields/config/types.ts index 9e4bb2ec9..70e01e2b4 100644 --- a/src/fields/config/types.ts +++ b/src/fields/config/types.ts @@ -165,10 +165,7 @@ export type GroupField = FieldBase & { } } -export type RowAdmin = Omit & { - readOnly?: false; - hidden?: false; -}; +export type RowAdmin = Omit; export type RowField = Omit & { admin?: RowAdmin; @@ -176,6 +173,12 @@ export type RowField = Omit & { fields: Field[]; } +export type CollapsibleField = Omit & { + type: 'collapsible'; + label: string + fields: Field[]; +} + export type UIField = { name: string label?: string @@ -328,6 +331,7 @@ export type Field = | CodeField | PointField | RowField + | CollapsibleField | UIField; export type FieldAffectingData = @@ -364,7 +368,8 @@ export type NonPresentationalField = TextField | UploadField | CodeField | PointField - | RowField; + | RowField + | CollapsibleField; export type FieldWithPath = Field & { path?: string @@ -373,7 +378,8 @@ export type FieldWithPath = Field & { export type FieldWithSubFields = GroupField | ArrayField - | RowField; + | RowField + | CollapsibleField; export type FieldPresentationalOnly = UIField; @@ -387,7 +393,7 @@ export type FieldWithMaxDepth = | RelationshipField export function fieldHasSubFields(field: Field): field is FieldWithSubFields { - return (field.type === 'group' || field.type === 'array' || field.type === 'row'); + return (field.type === 'group' || field.type === 'array' || field.type === 'row' || field.type === 'collapsible'); } export function fieldIsArrayType(field: Field): field is ArrayField { diff --git a/src/fields/hooks/afterChange/promise.ts b/src/fields/hooks/afterChange/promise.ts index 5a8b08ba1..483b728e9 100644 --- a/src/fields/hooks/afterChange/promise.ts +++ b/src/fields/hooks/afterChange/promise.ts @@ -111,7 +111,8 @@ export const promise = async ({ break; } - case 'row': { + case 'row': + case 'collapsible': { traverseFields({ data, doc, diff --git a/src/fields/hooks/afterRead/promise.ts b/src/fields/hooks/afterRead/promise.ts index 7d49aac44..4f061af9d 100644 --- a/src/fields/hooks/afterRead/promise.ts +++ b/src/fields/hooks/afterRead/promise.ts @@ -246,7 +246,8 @@ export const promise = async ({ break; } - case 'row': { + case 'row': + case 'collapsible': { traverseFields({ currentDepth, depth, diff --git a/src/fields/hooks/beforeChange/promise.ts b/src/fields/hooks/beforeChange/promise.ts index 8459bc67c..2912d30ef 100644 --- a/src/fields/hooks/beforeChange/promise.ts +++ b/src/fields/hooks/beforeChange/promise.ts @@ -259,7 +259,8 @@ export const promise = async ({ break; } - case 'row': { + case 'row': + case 'collapsible': { traverseFields({ data, doc, diff --git a/src/fields/hooks/beforeValidate/promise.ts b/src/fields/hooks/beforeValidate/promise.ts index b1983f165..8b2047dad 100644 --- a/src/fields/hooks/beforeValidate/promise.ts +++ b/src/fields/hooks/beforeValidate/promise.ts @@ -249,7 +249,8 @@ export const promise = async ({ break; } - case 'row': { + case 'row': + case 'collapsible': { traverseFields({ data, doc, diff --git a/src/graphql/schema/buildMutationInputType.ts b/src/graphql/schema/buildMutationInputType.ts index c2805395b..ecbfb1c0a 100644 --- a/src/graphql/schema/buildMutationInputType.ts +++ b/src/graphql/schema/buildMutationInputType.ts @@ -15,7 +15,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, fieldHasSubFields, fieldAffectsData, fieldIsPresentationalOnly, GroupField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TextareaField, TextField, UploadField } from '../../fields/config/types'; +import { ArrayField, CodeField, DateField, EmailField, Field, fieldHasSubFields, fieldAffectsData, fieldIsPresentationalOnly, GroupField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TextareaField, TextField, UploadField, CollapsibleField } from '../../fields/config/types'; import { toWords } from '../../utilities/formatLabels'; import { Payload } from '../../index'; import { SanitizedCollectionConfig } from '../../collections/config/types'; @@ -135,6 +135,20 @@ function buildMutationInputType(payload: Payload, name: string, fields: Field[], ]; } + return acc; + }, []), + collapsible: (field: CollapsibleField) => field.fields.reduce((acc, collapsibleField: CollapsibleField) => { + const getFieldSchema = fieldToSchemaMap[collapsibleField.type]; + + if (getFieldSchema) { + const fieldSchema = getFieldSchema(collapsibleField); + + return [ + ...acc, + fieldSchema, + ]; + } + return acc; }, []), }; diff --git a/src/graphql/schema/buildObjectType.ts b/src/graphql/schema/buildObjectType.ts index 85968e8a5..3c623ef9f 100644 --- a/src/graphql/schema/buildObjectType.ts +++ b/src/graphql/schema/buildObjectType.ts @@ -429,6 +429,18 @@ function buildObjectType(payload: Payload, name: string, fields: Field[], parent }; } + return subFieldSchema; + }, {}), + collapsible: (field) => field.fields.reduce((subFieldSchema, subField) => { + const buildSchemaType = fieldToSchemaMap[subField.type]; + + if (!fieldIsPresentationalOnly(subField) && buildSchemaType) { + return { + ...subFieldSchema, + [formatName(subField.name)]: buildSchemaType(subField), + }; + } + return subFieldSchema; }, {}), }; diff --git a/src/graphql/schema/fieldToWhereInputSchemaMap.ts b/src/graphql/schema/fieldToWhereInputSchemaMap.ts index 4492f1f83..40dae42ee 100644 --- a/src/graphql/schema/fieldToWhereInputSchemaMap.ts +++ b/src/graphql/schema/fieldToWhereInputSchemaMap.ts @@ -11,7 +11,7 @@ import { GraphQLJSON } from 'graphql-type-json'; import { ArrayField, CheckboxField, - CodeField, DateField, + CodeField, CollapsibleField, DateField, EmailField, fieldAffectsData, fieldHasSubFields, GroupField, NumberField, optionIsObject, PointField, RadioField, RelationshipField, @@ -220,24 +220,51 @@ const fieldToSchemaMap: (parentName: string) => any = (parentName: string) => ({ }), array: (field: ArrayField) => recursivelyBuildNestedPaths(parentName, field), group: (field: GroupField) => recursivelyBuildNestedPaths(parentName, field), - row: (field: RowField) => field.fields.reduce((rowSchema, rowField) => { - const getFieldSchema = fieldToSchemaMap(parentName)[rowField.type]; + row: (field: RowField) => field.fields.reduce((rowSchema, subField) => { + const getFieldSchema = fieldToSchemaMap(parentName)[subField.type]; if (getFieldSchema) { - const rowFieldSchema = getFieldSchema(rowField); + const rowFieldSchema = getFieldSchema(subField); - if (fieldHasSubFields(rowField)) { + if (fieldHasSubFields(subField)) { return [ ...rowSchema, ...rowFieldSchema, ]; } - if (fieldAffectsData(rowField)) { + if (fieldAffectsData(subField)) { return [ ...rowSchema, { - key: rowField.name, + key: subField.name, + type: rowFieldSchema, + }, + ]; + } + } + + + return rowSchema; + }, []), + collapsible: (field: CollapsibleField) => field.fields.reduce((rowSchema, subField) => { + const getFieldSchema = fieldToSchemaMap(parentName)[subField.type]; + + if (getFieldSchema) { + const rowFieldSchema = getFieldSchema(subField); + + if (fieldHasSubFields(subField)) { + return [ + ...rowSchema, + ...rowFieldSchema, + ]; + } + + if (fieldAffectsData(subField)) { + return [ + ...rowSchema, + { + key: subField.name, type: rowFieldSchema, }, ]; diff --git a/src/mongoose/buildSchema.ts b/src/mongoose/buildSchema.ts index 9966c1f12..6e28ef6be 100644 --- a/src/mongoose/buildSchema.ts +++ b/src/mongoose/buildSchema.ts @@ -4,7 +4,7 @@ /* eslint-disable no-use-before-define */ import { Schema, SchemaDefinition, SchemaOptions } from 'mongoose'; import { SanitizedConfig } from '../config/types'; -import { ArrayField, Block, BlockField, CheckboxField, CodeField, DateField, EmailField, Field, fieldAffectsData, GroupField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TextareaField, TextField, UploadField, fieldIsPresentationalOnly, NonPresentationalField } from '../fields/config/types'; +import { ArrayField, Block, BlockField, CheckboxField, CodeField, DateField, EmailField, Field, fieldAffectsData, GroupField, NumberField, PointField, RadioField, RelationshipField, RichTextField, RowField, SelectField, TextareaField, TextField, UploadField, fieldIsPresentationalOnly, NonPresentationalField, CollapsibleField } from '../fields/config/types'; import sortableFieldTypes from '../fields/sortableFieldTypes'; export type BuildSchemaOptions = { @@ -306,12 +306,26 @@ const fieldToSchemaMap = { row: (field: RowField, fields: SchemaDefinition, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): SchemaDefinition => { const newFields = { ...fields }; - field.fields.forEach((rowField: Field) => { - const fieldSchemaMap: FieldSchemaGenerator = fieldToSchemaMap[rowField.type]; + field.fields.forEach((subField: Field) => { + const fieldSchemaMap: FieldSchemaGenerator = fieldToSchemaMap[subField.type]; - if (fieldSchemaMap && fieldAffectsData(rowField)) { - const fieldSchema = fieldSchemaMap(rowField, fields, config, buildSchemaOptions); - newFields[rowField.name] = fieldSchema[rowField.name]; + if (fieldSchemaMap && fieldAffectsData(subField)) { + const fieldSchema = fieldSchemaMap(subField, fields, config, buildSchemaOptions); + newFields[subField.name] = fieldSchema[subField.name]; + } + }); + + return newFields; + }, + collapsible: (field: CollapsibleField, fields: SchemaDefinition, config: SanitizedConfig, buildSchemaOptions: BuildSchemaOptions): SchemaDefinition => { + const newFields = { ...fields }; + + field.fields.forEach((subField: Field) => { + const fieldSchemaMap: FieldSchemaGenerator = fieldToSchemaMap[subField.type]; + + if (fieldSchemaMap && fieldAffectsData(subField)) { + const fieldSchema = fieldSchemaMap(subField, fields, config, buildSchemaOptions); + newFields[subField.name] = fieldSchema[subField.name]; } }); diff --git a/test/e2e/fields/config.ts b/test/e2e/fields/config.ts index 93554bfa0..948cdf69d 100644 --- a/test/e2e/fields/config.ts +++ b/test/e2e/fields/config.ts @@ -1,9 +1,74 @@ import { buildConfig } from '../buildConfig'; import { devUser } from '../../credentials'; -import { textDoc } from './shared'; +import { arrayDoc, blocksDoc, collapsibleDoc, textDoc } from './shared'; export default buildConfig({ collections: [ + { + slug: 'array-fields', + fields: [ + { + name: 'array', + type: 'array', + required: true, + fields: [ + { + name: 'text', + type: 'text', + required: true, + }, + ], + }, + ], + }, + { + slug: 'block-fields', + fields: [ + { + name: 'blocks', + type: 'blocks', + required: true, + blocks: [ + { + slug: 'text', + fields: [ + { + name: 'text', + type: 'text', + required: true, + }, + ], + }, + { + slug: 'number', + fields: [ + { + name: 'number', + type: 'number', + required: true, + }, + ], + }, + ], + }, + ], + }, + { + slug: 'collapsible-fields', + fields: [ + { + label: 'Collapsible Field', + type: 'collapsible', + fields: [ + { + name: 'text', + type: 'text', + required: true, + }, + ], + }, + ], + }, { slug: 'text-fields', admin: { @@ -27,6 +92,21 @@ export default buildConfig({ }, }); + await payload.create({ + collection: 'array-fields', + data: arrayDoc, + }); + + await payload.create({ + collection: 'block-fields', + data: blocksDoc, + }); + + await payload.create({ + collection: 'collapsible-fields', + data: collapsibleDoc, + }); + await payload.create({ collection: 'text-fields', data: textDoc, diff --git a/test/e2e/fields/shared.ts b/test/e2e/fields/shared.ts index db0346e5b..08f1b016b 100644 --- a/test/e2e/fields/shared.ts +++ b/test/e2e/fields/shared.ts @@ -1,3 +1,45 @@ +export const arrayDoc = { + array: [ + { + text: 'first row', + }, + { + text: 'second row', + }, + { + text: 'third row', + }, + { + text: 'fourth row', + }, + { + text: 'fifth row', + }, + { + text: 'sixth row', + }, + ], +}; + +export const blocksDoc = { + blocks: [ + { + blockName: 'First block', + blockType: 'text', + text: 'first block', + }, + { + blockName: 'Second block', + blockType: 'number', + number: 342, + }, + ], +}; + export const textDoc = { text: 'Seeded text document', }; + +export const collapsibleDoc = { + text: 'Seeded collapsible doc', +};