diff --git a/package.json b/package.json index 34b7282f5a..e93100da5b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@babel/preset-react": "^7.8.3", "@babel/runtime": "^7.8.3", "@hapi/joi": "^15.1.1", + "@trbl/react-modal": "0.1.0", "accept-language-parser": "^1.5.0", "async-some": "^1.0.2", "babel-jest": "^24.9.0", diff --git a/src/client/components/forms/Form/index.js b/src/client/components/forms/Form/index.js index bb56f5f74a..3d7c3ac873 100644 --- a/src/client/components/forms/Form/index.js +++ b/src/client/components/forms/Form/index.js @@ -123,8 +123,6 @@ const Form = (props) => { baseClass, ].filter(Boolean).join(' '); - // console.log(fields); - return (
{ + const { + addRow, + blocks, + rowIndexBeingAdded, + closeAllModals, + } = props; + + const handleAddRow = (blockType) => { + addRow(rowIndexBeingAdded, blockType); + closeAllModals(); + }; + + return ( +
+ +
+ ); +}; + +AddRowModal.defaultProps = { + rowIndexBeingAdded: null, +}; + +AddRowModal.propTypes = { + addRow: PropTypes.func.isRequired, + closeAllModals: PropTypes.func.isRequired, + blocks: PropTypes.arrayOf( + PropTypes.shape({ + labels: PropTypes.shape({ + singular: PropTypes.string, + }), + previewImage: PropTypes.string, + slug: PropTypes.string, + }), + ).isRequired, + rowIndexBeingAdded: PropTypes.number, +}; + +export default asModal(AddRowModal); diff --git a/src/client/components/forms/field-types/Flexible/FlexibleRow/index.js b/src/client/components/forms/field-types/Flexible/FlexibleRow/index.js new file mode 100644 index 0000000000..a1e37999e0 --- /dev/null +++ b/src/client/components/forms/field-types/Flexible/FlexibleRow/index.js @@ -0,0 +1,128 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import AnimateHeight from 'react-animate-height'; +import { Draggable } from 'react-beautiful-dnd'; + +// eslint-disable-next-line import/no-cycle +import RenderFields from '../../../RenderFields'; +import IconButton from '../../../../controls/IconButton'; + +import './index.scss'; + +const baseClass = 'flexible-row'; + +const FlexibleRow = (props) => { + const { + addRow, + removeRow, + rowIndex, + parentName, + block, + defaultValue, + dispatchRows, + rows, + } = props; + + const handleCollapseClick = () => { + dispatchRows({ + type: 'UPDATE_IS_ROW_OPEN', + rowIndex, + }); + }; + + return ( + + {(providedDrag) => { + return ( +
+
+
+ +
+ {`${block.labels.singular} ${rowIndex + 1 > 9 ? rowIndex + 1 : `0${rowIndex + 1}`}`} +
+ +
+ + + + + + +
+
+ + + { + const fieldName = `${parentName}.${rowIndex}.${field.name}`; + return ({ + ...field, + name: fieldName, + defaultValue: defaultValue?.[field.name], + }); + })} + /> + +
+ ); + }} + + ); +}; + +FlexibleRow.defaultProps = { + defaultValue: null, + rows: [], +}; + +FlexibleRow.propTypes = { + block: PropTypes.shape({ + labels: PropTypes.shape({ + singular: PropTypes.string, + }), + fields: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + slug: PropTypes.string, + }).isRequired, + addRow: PropTypes.func.isRequired, + removeRow: PropTypes.func.isRequired, + rowIndex: PropTypes.number.isRequired, + parentName: PropTypes.string.isRequired, + fieldState: PropTypes.shape({}).isRequired, + defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.shape({})]), + dispatchRows: PropTypes.func.isRequired, + rows: PropTypes.arrayOf(PropTypes.shape({ + isOpen: PropTypes.bool, + blockType: PropTypes.string, + })), +}; + +export default FlexibleRow; diff --git a/src/client/components/forms/field-types/Flexible/FlexibleRow/index.scss b/src/client/components/forms/field-types/Flexible/FlexibleRow/index.scss new file mode 100644 index 0000000000..dbaa9483be --- /dev/null +++ b/src/client/components/forms/field-types/Flexible/FlexibleRow/index.scss @@ -0,0 +1,90 @@ +@import '../../../../../scss/styles.scss'; + +.flexible-row { + background: $light-gray; + flex-direction: column; + position: relative; + margin-top: base(.5); + + &:hover { + @include shadow-sm; + } + + &__collapse__icon { + &--open { + svg { + transform: rotate(90deg); + } + } + + &--closed { + svg { + transform: rotate(-90deg); + } + } + } + + &__header { + padding: base(.75) base(1); + display: flex; + align-items: center; + position: relative; + + &__drag-handle { + width: 100%; + height: 100%; + position: absolute; + left: 0; + z-index: 1; + } + + // elements above the drag handle + &__controls, + &__header__row-index { + position: relative; + z-index: 2; + } + + &__row-index { + font-family: $font-body; + font-size: base(.5); + } + + &__heading { + font-family: $font-body; + margin: 0; + font-size: base(.65); + } + + &__controls { + margin-left: auto; + + .btn { + margin-top: 0; + margin-bottom: 0; + } + + .icon-button--crossOut, + .icon-button--crosshair { + margin-right: base(.25); + } + + .icon-button--crosshair { + border-color: $primary; + @include color-svg($primary); + + &:hover { + background: lighten($primary, 50%); + } + } + } + } + + &__content { + box-shadow: inset 0px 1px 0px white; + + > div { + padding: base(.75) base(1); + } + } +} diff --git a/src/client/components/forms/field-types/Flexible/index.js b/src/client/components/forms/field-types/Flexible/index.js new file mode 100644 index 0000000000..e966dcfb5a --- /dev/null +++ b/src/client/components/forms/field-types/Flexible/index.js @@ -0,0 +1,160 @@ +import React, { + useContext, useEffect, useReducer, useState, Fragment, +} from 'react'; +import PropTypes from 'prop-types'; +import { DragDropContext, Droppable } from 'react-beautiful-dnd'; +import { ModalContext } from '@trbl/react-modal'; + +import FormContext from '../../Form/Context'; +import Section from '../../../layout/Section'; +import FlexibleRow from './FlexibleRow'; // eslint-disable-line import/no-cycle +import AddRowModal from './AddRowModal'; +import rowReducer from './reducer'; + +import './index.scss'; + +const baseClass = 'field-type flexible'; + +const Flexible = (props) => { + const { + label, + name, + blocks, + defaultValue, + } = props; + + const { toggle: toggleModal, closeAll: closeAllModals } = useContext(ModalContext); + const [rowIndexBeingAdded, setRowIndexBeingAdded] = useState(null); + const [rows, dispatchRows] = useReducer(rowReducer, []); + const formContext = useContext(FormContext); + const rowCount = rows.length; + const modalSlug = `flexible-${name}`; + + const { fields: fieldState, dispatchFields } = formContext; + + const addRow = (rowIndex, blockType) => { + const blockToAdd = blocks.find(block => block.slug === blockType); + + dispatchFields({ + type: 'ADD_ROW', rowIndex, name, fields: blockToAdd.fields, + }); + + dispatchRows({ + type: 'ADD', rowIndex, blockType, + }); + }; + + const removeRow = (rowIndex) => { + dispatchFields({ + type: 'REMOVE_ROW', rowIndex, name, + }); + + dispatchRows({ + type: 'REMOVE', + rowIndex, + }); + }; + + const moveRow = (moveFromIndex, moveToIndex) => { + dispatchRows({ + type: 'MOVE', rowIndex: moveFromIndex, moveToIndex, + }); + + dispatchFields({ + type: 'MOVE_ROW', moveFromIndex, moveToIndex, name, + }); + }; + + useEffect(() => { + if (defaultValue) { + dispatchRows({ + type: 'LOAD_ROWS', + payload: defaultValue, + }); + } + }, [defaultValue]); + + const openAddRowModal = (rowIndex) => { + setRowIndexBeingAdded(rowIndex); + toggleModal(modalSlug); + }; + + const onDragEnd = (result) => { + if (!result.destination) return; + const sourceIndex = result.source.index; + const destinationIndex = result.destination.index; + moveRow(sourceIndex, destinationIndex); + }; + + return ( + + +
+
openAddRowModal(0)} + useAddRowButton + > + + {provided => ( +
+ {rowCount !== 0 + && rows.map((row, rowIndex) => { + const blockToRender = blocks.find(block => block.slug === row.blockType); + + return ( + openAddRowModal(rowIndex)} + removeRow={() => removeRow(rowIndex)} + rowIndex={rowIndex} + fieldState={fieldState} + block={blockToRender} + defaultValue={defaultValue[rowIndex]} + dispatchRows={dispatchRows} + rows={rows} + /> + ); + }) + } + {provided.placeholder} +
+ )} +
+
+
+
+ +
+ ); +}; + +Flexible.defaultProps = { + label: '', + defaultValue: [], +}; + +Flexible.propTypes = { + defaultValue: PropTypes.arrayOf( + PropTypes.shape({}), + ), + blocks: PropTypes.arrayOf( + PropTypes.shape({}), + ).isRequired, + label: PropTypes.string, + name: PropTypes.string.isRequired, +}; + +export default Flexible; diff --git a/src/client/components/forms/field-types/Flexible/index.scss b/src/client/components/forms/field-types/Flexible/index.scss new file mode 100644 index 0000000000..617a36d1ad --- /dev/null +++ b/src/client/components/forms/field-types/Flexible/index.scss @@ -0,0 +1,3 @@ +.field-type.flexible { + background: white; +} diff --git a/src/client/components/forms/field-types/Flexible/reducer.js b/src/client/components/forms/field-types/Flexible/reducer.js new file mode 100644 index 0000000000..8e1516a51c --- /dev/null +++ b/src/client/components/forms/field-types/Flexible/reducer.js @@ -0,0 +1,48 @@ +const rowReducer = (currentState, action) => { + const { + type, rowIndex, moveToIndex, payload, blockType, + } = action; + + const newState = [...currentState]; + + switch (type) { + case 'LOAD_ROWS': + return payload.reduce((acc, row) => { + acc.push({ + blockType: row.blockType, + isOpen: true, + }); + + return acc; + }, []); + + case 'ADD': + newState.splice(rowIndex + 1, 0, { blockType, isOpen: true }); + return newState; + + case 'REMOVE': + newState.splice(rowIndex, 1); + return newState; + + case 'UPDATE_IS_ROW_OPEN': { + const movingRow = newState[rowIndex]; + newState[rowIndex] = { + ...movingRow, + isOpen: !movingRow.isOpen, + }; + return newState; + } + + case 'MOVE': { + const movingRow = newState[rowIndex]; + newState.splice(rowIndex, 1); + newState.splice(moveToIndex, 0, movingRow); + return newState; + } + + default: + return currentState; + } +}; + +export default rowReducer; diff --git a/src/client/components/forms/field-types/Repeater/RepeaterRow/index.js b/src/client/components/forms/field-types/Repeater/RepeaterRow/index.js index 8b60e83c1a..9b82743c5f 100644 --- a/src/client/components/forms/field-types/Repeater/RepeaterRow/index.js +++ b/src/client/components/forms/field-types/Repeater/RepeaterRow/index.js @@ -101,7 +101,6 @@ RepeaterRow.propTypes = { rowIndex: PropTypes.number.isRequired, parentName: PropTypes.string.isRequired, fields: PropTypes.arrayOf(PropTypes.shape({})).isRequired, - fieldState: PropTypes.shape({}).isRequired, rowCount: PropTypes.number, defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.shape({})]), dispatchCollapsibleStates: PropTypes.func.isRequired, diff --git a/src/client/components/forms/field-types/Repeater/index.js b/src/client/components/forms/field-types/Repeater/index.js index b197fc7ef9..a9d8ed1881 100644 --- a/src/client/components/forms/field-types/Repeater/index.js +++ b/src/client/components/forms/field-types/Repeater/index.js @@ -94,23 +94,22 @@ const Repeater = (props) => { {...provided.droppableProps} > {rowCount !== 0 - && Array.from(Array(rowCount).keys()).map((_, rowIndex) => { - return ( - addRow(rowIndex)} - removeRow={() => removeRow(rowIndex)} - rowIndex={rowIndex} - fieldState={fieldState} - fields={fields} - rowCount={rowCount} - defaultValue={defaultValue[rowIndex]} - dispatchCollapsibleStates={dispatchCollapsibleStates} - collapsibleStates={collapsibleStates} - /> - ); - }) + && Array.from(Array(rowCount).keys()).map((_, rowIndex) => { + return ( + addRow(rowIndex)} + removeRow={() => removeRow(rowIndex)} + rowIndex={rowIndex} + fields={fields} + rowCount={rowCount} + defaultValue={defaultValue[rowIndex]} + dispatchCollapsibleStates={dispatchCollapsibleStates} + collapsibleStates={collapsibleStates} + /> + ); + }) } {provided.placeholder}
diff --git a/src/client/components/forms/field-types/index.js b/src/client/components/forms/field-types/index.js index 88c0403b4b..9c5707b156 100644 --- a/src/client/components/forms/field-types/index.js +++ b/src/client/components/forms/field-types/index.js @@ -6,6 +6,7 @@ import text from './Text'; import relationship from './Relationship'; import password from './Password'; import repeater from './Repeater'; +import flexible from './Flexible'; import textarea from './Textarea'; import select from './Select'; import number from './Number'; @@ -18,6 +19,7 @@ export default { text, relationship, // upload, + flexible, number, password, repeater, diff --git a/src/client/components/index.js b/src/client/components/index.js index f11481675c..61b3d745ff 100644 --- a/src/client/components/index.js +++ b/src/client/components/index.js @@ -1,6 +1,7 @@ import React, { Suspense } from 'react'; import { render } from 'react-dom'; import { BrowserRouter as Router } from 'react-router-dom'; +import { ModalProvider, ModalContainer } from '@trbl/react-modal'; import Loading from './views/Loading'; import { SearchParamsProvider } from './utilities/SearchParams'; import { LocaleProvider } from './utilities/Locale'; @@ -14,15 +15,21 @@ const Index = () => { return ( - - - - }> - - - - - + + + + + }> + + + + + + + ); diff --git a/src/client/components/modals/asModal/index.js b/src/client/components/modals/asModal/index.js deleted file mode 100644 index e860cd63dc..0000000000 --- a/src/client/components/modals/asModal/index.js +++ /dev/null @@ -1,102 +0,0 @@ -/////////////////////////////////////////////////////// -// Takes a modal component and -// a slug to match against a 'modal' URL param -/////////////////////////////////////////////////////// - -import React, { Component } from 'react'; -import { createPortal } from 'react-dom'; -import { withRouter } from 'react-router'; -import queryString from 'qs'; -import Close from '../../graphics/Close'; -import Button from '../../controls/Button'; - -import './index.scss'; - -const asModal = (PassedComponent, modalSlug) => { - - class AsModal extends Component { - - constructor(props) { - super(props); - - this.state = { - open: false, - el: null - } - } - - bindEsc = event => { - if (event.keyCode === 27) { - const params = { ...this.props.searchParams }; - delete params.modal; - - this.props.history.push({ - search: queryString.stringify(params) - }) - } - } - - isOpen = () => { - - // Slug can come from either a HOC or from a prop - const slug = this.props.modalSlug ? this.props.modalSlug : modalSlug; - - if (this.props.searchParams.modal === slug) { - return true; - } - - return false; - } - - componentDidMount() { - document.addEventListener('keydown', this.bindEsc, false); - - if (this.isOpen()) { - this.setState({ open: true }) - } - - // Slug can come from either a HOC or from a prop - const slug = this.props.modalSlug ? this.props.modalSlug : modalSlug; - - this.setState({ - el: document.querySelector(`#${slug}`) - }) - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.bindEsc, false); - } - - componentDidUpdate(prevProps, prevState) { - - let open = this.isOpen(); - - if (open !== prevState.open && open) { - this.setState({ open: true }) - } else if (open !== prevState.open) { - this.setState({ open: false }) - } - } - - render() { - - // Slug can come from either a HOC or from a prop - const slug = this.props.modalSlug ? this.props.modalSlug : modalSlug; - const modalDomNode = document.getElementById('portal'); - - return createPortal( -
- - -
, - modalDomNode - ); - } - } - - return withRouter(connect(mapStateToProps)(AsModal)); -} - -export default asModal; diff --git a/src/client/components/modals/asModal/index.scss b/src/client/components/modals/asModal/index.scss deleted file mode 100644 index c2dda34f77..0000000000 --- a/src/client/components/modals/asModal/index.scss +++ /dev/null @@ -1,21 +0,0 @@ -@import '../../../scss/styles.scss'; - -.modal { - transform: translateZ(0); - opacity: 0; - visibility: hidden; - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: -1; - height: 100vh; - background-color: rgba(white, .96); - - &.open { - opacity: 1; - visibility: visible; - z-index: 100; - } -} diff --git a/src/mongoose/schema/buildSchema.js b/src/mongoose/schema/buildSchema.js index ffe913b3f5..59b8b1c5e8 100644 --- a/src/mongoose/schema/buildSchema.js +++ b/src/mongoose/schema/buildSchema.js @@ -31,7 +31,7 @@ const buildSchema = (configFields, config, options = {}, additionalBaseFields = }); const blockSchema = new Schema(blockSchemaFields, { _id: false }); - schema.path(field.name).discriminator(block.labels.singular, blockSchema); + schema.path(field.name).discriminator(block.slug, blockSchema); }); } }); diff --git a/yarn.lock b/yarn.lock index 7811adf328..cccffb25f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1187,6 +1187,28 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" +"@trbl/react-html-element@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@trbl/react-html-element/-/react-html-element-1.0.1.tgz#d8936ad47f64b91257a331789eaf73c324182045" + integrity sha512-I6qU3n9fxLS1TRgF0lQuYA+c6GEcOFNFzyTxy1Voz0B9JsIIFB/1Ky+H/rADfDfRgKSVxoZ0fLAN0zqoE/YwKA== + dependencies: + prop-types "^15.7.2" + react "^16.12.0" + react-dom "^16.12.0" + +"@trbl/react-modal@0.1.0": + version "0.1.0" + resolved "https://registry.yarnpkg.com/@trbl/react-modal/-/react-modal-0.1.0.tgz#23e9f24aeed4dea2a79b4ed23a3d5e50ee71ab26" + integrity sha512-Hxmz0ML5s0F+obGm17mNOD4fDgfTOayDqk/kT9W1roMYVa2OQ5zM5On9DMteQNtwz4N3K3eviLEr61OycjEpfA== + dependencies: + "@trbl/react-html-element" "^1.0.1" + minify-css-string "^1.0.0" + prop-types "^15.7.2" + qs "^6.9.1" + react "^16.12.0" + react-dom "^16.12.0" + react-transition-group "^4.3.0" + "@types/babel__core@^7.1.0": version "7.1.6" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.6.tgz#16ff42a5ae203c9af1c6e190ed1f30f83207b610" @@ -3842,9 +3864,9 @@ ejs@^2.6.1: integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA== electron-to-chromium@^1.3.380: - version "1.3.382" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.382.tgz#cad02da655c33f7a3d6ca7525bd35c17e90f3a8f" - integrity sha512-gJfxOcgnBlXhfnUUObsq3n3ReU8CT6S8je97HndYRkKsNZMJJ38zO/pI5aqO7L3Myfq+E3pqPyKK/ynyLEQfBA== + version "1.3.383" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.383.tgz#8bbef55963529bfbf8344ac3620e1bcb455cffc3" + integrity sha512-EHYVJl6Ox1kFy/SzGVbijHu8ksQotJnqHCFFfaVhXiC+erOSplwhCtOTSocu1jRwirlNsSn/aZ9Kf84Z6s5qrg== elliptic@^6.0.0: version "6.5.2" @@ -4505,9 +4527,9 @@ fb-watchman@^2.0.0: bser "2.1.1" figgy-pudding@^3.5.1: - version "3.5.1" - resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" - integrity sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w== + version "3.5.2" + resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" + integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw== figures@^3.0.0: version "3.2.0" @@ -7531,6 +7553,11 @@ mini-create-react-context@^0.3.0: gud "^1.0.0" tiny-warning "^1.0.2" +minify-css-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/minify-css-string/-/minify-css-string-1.0.0.tgz#201bd949271e19f6e0af0a1dc0ccc583de47c630" + integrity sha1-IBvZSSceGfbgrwodwMzFg95HxjA= + minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"