UNPKG

acklen-keystone

Version:

Web Application Framework and Admin GUI / Content Management System built on Express.js and Mongoose

835 lines (782 loc) 24.5 kB
import React from 'react'; import { browserHistory } from 'react-router'; import moment from 'moment'; import assign from 'object-assign'; import xhr from 'xhr'; import SortableTree, { removeNodeAtPath } from 'react-sortable-tree'; import { Form, FormField, FormInput, Grid, ResponsiveText, } from '../../../elemental'; import { Fields } from 'FieldTypes'; import { fade } from '../../../../utils/color'; import theme from '../../../../theme'; import { Button, LoadingButton } from '../../../elemental'; import AlertMessages from '../../../shared/AlertMessages'; import ConfirmationDialog from './../../../shared/ConfirmationDialog'; import FormHeading from './FormHeading'; import AltText from './AltText'; import FooterBar from './FooterBar'; import InvalidFieldType from '../../../shared/InvalidFieldType'; import { deleteItem } from '../actions'; import { upcase } from '../../../../utils/string'; function getNameFromData (data) { if (typeof data === 'object') { if (typeof data.first === 'string' && typeof data.last === 'string') { return data.first + ' ' + data.last; } else if (data.id) { return data.id; } } return data; } function smoothScrollTop () { const element = document.getElementById('main-container'); if (element.scrollTop) { element.scrollTop = element.scrollTop - 50; var timeOut = setTimeout(smoothScrollTop, 20); } else { clearTimeout(timeOut); } } var EditForm = React.createClass({ displayName: 'EditForm', propTypes: { data: React.PropTypes.object, list: React.PropTypes.object, }, getInitialState () { let treeData1 = []; let treeData2 = []; let modulesRef = ''; const modulesField = this.props.list.columns .find((item) => item.path === 'modules'); if (modulesField) { const modules = []; treeData1 = modulesField.field.ops .map((op) => { const moduleField = this.props.data.fields[op.value]; if (moduleField) { moduleField.forEach((item) => { modules.push({ id: item, title: op.label, path: op.value, value: item, selectedModuleId: item, }); }); } return { title: op.label, path: op.value, value: '', selectedModuleId: '' }; }); const modulesRefArr = this.props.data.fields.modulesRef && this.props.data.fields.modulesRef.split('|') || []; modulesRefArr.forEach((value) => { const module = modules.find((item) => { let arr = [value]; if (value.indexOf(',') > 0) { arr = value.split(','); } return arr.indexOf(item.selectedModuleId) > -1; }); if (module) { const id = this.uuidv4(); treeData2.push({ ...module, id, value }); this.props.data.fields[`${module.path}_${id}`] = value; // module.value; } }); modulesRef = this.props.data.fields.modulesRef; } return { values: assign({}, this.props.data.fields), confirmationDialog: null, loading: true, isEditor: false, isAdministrator: false, isContributor: false, lastValues: null, // used for resetting focusFirstField: !this.props.list.nameField && !this.props.list.nameFieldIsFormHeader, treeData1: treeData1, treeData2: treeData2, modulesRef: { id: 'modulesRef', name: 'modulesRef', value: modulesRef, }, isPageBuilder: this.props.list.id.indexOf('page-builder') > -1, isModelVersion: this.props.list.id.indexOf('versions') > -1, }; }, componentDidMount () { this.setLoggedUser(); this.__isMounted = true; }, componentWillUnmount () { this.__isMounted = false; }, setLoggedUser () { xhr({ url: `${Keystone.adminPath}/api/session`, method: 'get', headers: assign({}, Keystone.csrf.header), }, (err, resp, body) => { var loggedUser = JSON.parse(body); if (this.__isMounted) { this.setState({ loading: false, isAdministrator: loggedUser.user.isAdministrator, isContributor: loggedUser.user.isContributor, isEditor: loggedUser.user.isEditor, }); } }); }, getFieldProps (field) { const props = assign({}, field); const alerts = this.state.alerts; // Display validation errors inline if (alerts && alerts.error && alerts.error.error === 'validation errors') { if (alerts.error.detail[field.path]) { // NOTE: This won't work yet, as ElementalUI doesn't allow // passed in isValid, only invalidates via internal state. // PR to fix that: https://github.com/elementalui/elemental/pull/149 props.isValid = false; } } let value = this.state.values[field.path]; if (!value && (['text', 'date', 'number'].indexOf(field.type) !== -1)) { value = ''; } props.value = value; props.values = this.state.values; props.onChange = this.handleChange; props.mode = 'edit'; return props; }, handleChange (event) { const { isPageBuilder } = this.state; const values = assign({}, this.state.values); values[event.path] = event.value; this.setState({ values }); if (isPageBuilder) { if (event.path) { const modulesRef = assign({}, this.state.modulesRef); const treeData2 = assign([], this.state.treeData2); const selectedValue = treeData2.find((item) => `${item.path}_${item.id}` === event.path ); if (selectedValue) { const arrModulesRef = []; selectedValue.selectedModuleId = event.value; selectedValue.value = event.value; treeData2.forEach((item) => { if (item.value) { arrModulesRef.push(item.value); } }); modulesRef.value = arrModulesRef.join('|'); this.setState({ treeData2, modulesRef, }); } if (event.path.indexOf('graphicBannerModules') > -1) { const arrValues = []; treeData2.forEach((item) => { if (event.path.indexOf(item.path) > -1) { const graphicBannerValues = item.value.split(','); graphicBannerValues.forEach((graphicBanner) => { if (arrValues.indexOf(graphicBanner) === -1) { arrValues.push(graphicBanner); } }); } }); values.graphicBannerModules = arrValues; this.setState({ values }); } if (event.path.indexOf('simpleCardModules') > -1) { const arrValues = []; treeData2.forEach((item) => { if (event.path.indexOf(item.path) > -1) { const simpleCardValues = item.value.split(','); simpleCardValues.forEach((simpleCard) => { if (arrValues.indexOf(simpleCard) === -1) { arrValues.push(simpleCard); } }); } }); values.simpleCardModules = arrValues; this.setState({ values }); } } } }, toggleDeleteDialog () { this.setState({ deleteDialogIsOpen: !this.state.deleteDialogIsOpen, }); }, toggleResetDialog () { this.setState({ resetDialogIsOpen: !this.state.resetDialogIsOpen, }); }, handleReset () { this.setState({ values: assign({}, this.state.lastValues || this.props.data.fields), resetDialogIsOpen: false, }); }, handleDelete () { const { data } = this.props; this.props.dispatch(deleteItem(data.id, this.props.router)); }, handleKeyFocus () { const input = this.refs.keyOrIdInput; input.select(); }, removeConfirmationDialog () { this.setState({ confirmationDialog: null, }); }, updateItem (state) { const { data, list } = this.props; const { values, treeData2, isPageBuilder } = this.state; const editForm = this.refs.editForm; const formData = new FormData(editForm); Object.keys(values).forEach((key) => { const field = list.fields[key]; if (key === 'modulesRef') { const arrModulesRef = []; treeData2.forEach((item) => { if (item.value) { arrModulesRef.push(item.value); } }); formData.delete(key); formData.append(key, arrModulesRef.join('|')); } else { if (field && field.type === 'relationship') { if (!values[key]) { formData.append(key, ''); } if (values[key] && values[key].length > 1 && (key.indexOf('graphicBannerModules') > -1 || key.indexOf('simpleCardModules') > -1)) { formData.delete(key, ''); formData.append(key, values[key].join(',')); } } } }); if (isPageBuilder && state) { if (list.fields.state) { formData.delete('state'); formData.append('state', state); formData.delete('isDraft'); formData.append('isDraft', state === 'draft'); } let moduleHasError = false; let errorMessage = 'please select a module'; this.state.treeData2.forEach((item) => { if (!item.value) { moduleHasError = true; } if (item.value.indexOf(',') > -1 && item.path.indexOf('Modules') > -1) { const arr = item.value.split(','); if (arr.length > 2 && item.path.indexOf('graphicBannerModules') > -1) { errorMessage = 'Maximum amount of Graphic Banner per module is two'; moduleHasError = true; } } }); if (moduleHasError) { this.setState({ alerts: { error: { error: errorMessage, }, }, loading: false, }); smoothScrollTop(); return; } } // Show loading indicator this.setState({ loading: true, }); list.updateItem(data.id, formData, (err, savedData) => { if (err && err.error === 'saved-draft') { browserHistory.push(`/keystone/page-builder-versions?filters=` + encodeURIComponent(`[{"path":"modelId","inverted":false,"value":["${data.id}"]}]`)); return; } smoothScrollTop(); if (err) { this.setState({ alerts: { error: err, }, loading: false, }); } else { // Success, display success flash messages, replace values // TODO: Update key value if (isPageBuilder) { this.state.treeData2.forEach((item) => { savedData.fields[`${item.path}_${item.id}`] = item.value; }); } this.setState({ alerts: { success: { success: 'Your changes have been saved successfully', }, }, lastValues: this.state.values, values: savedData.fields, loading: false, }); } }); }, renderKeyOrId () { var className = 'EditForm__key-or-id'; var list = this.props.list; if (list.nameField && list.autokey && this.props.data[list.autokey.path]) { return ( <div className={className}> <AltText modified="ID:" normal={`${upcase(list.autokey.path)}: `} title="Press <alt> to reveal the ID" className="EditForm__key-or-id__label" /> <AltText modified={<input ref="keyOrIdInput" onFocus={this.handleKeyFocus} value={this.props.data.id} className="EditForm__key-or-id__input" readOnly />} normal={<input ref="keyOrIdInput" onFocus={this.handleKeyFocus} value={this.props.data[list.autokey.path]} className="EditForm__key-or-id__input" readOnly />} title="Press <alt> to reveal the ID" className="EditForm__key-or-id__field" /> </div> ); } else if (list.autokey && this.props.data[list.autokey.path]) { return ( <div className={className}> <span className="EditForm__key-or-id__label">{list.autokey.path}: </span> <div className="EditForm__key-or-id__field"> <input ref="keyOrIdInput" onFocus={this.handleKeyFocus} value={this.props.data[list.autokey.path]} className="EditForm__key-or-id__input" readOnly /> </div> </div> ); } else if (list.nameField) { return ( <div className={className}> <span className="EditForm__key-or-id__label">ID: </span> <div className="EditForm__key-or-id__field"> <input ref="keyOrIdInput" onFocus={this.handleKeyFocus} value={this.props.data.id} className="EditForm__key-or-id__input" readOnly /> </div> </div> ); } }, renderNameField () { var nameField = this.props.list.nameField; var nameFieldIsFormHeader = this.props.list.nameFieldIsFormHeader; var wrapNameField = field => ( <div className="EditForm__name-field"> {field} </div> ); if (nameFieldIsFormHeader) { var nameFieldProps = this.getFieldProps(nameField); nameFieldProps.label = null; nameFieldProps.size = 'full'; nameFieldProps.autoFocus = true; nameFieldProps.inputProps = { className: 'item-name-field', placeholder: nameField.label, size: 'large', }; return wrapNameField( React.createElement(Fields[nameField.type], nameFieldProps) ); } else { return wrapNameField( <h2>{this.props.data.name || '(no name)'}</h2> ); } }, renderFormElements () { const { isPageBuilder } = this.state; let headings = 0; return this.props.list.uiElements.map((el, index) => { // Don't render the name field if it is the header since it'll be rendered in BIG above // the list. (see renderNameField method, this is the reverse check of the one it does) if ( this.props.list.nameField && el.field === this.props.list.nameField.path && this.props.list.nameFieldIsFormHeader ) return; if (!this.state.isAdministrator && this.props.list.id === 'users') { if (el.field === 'isAdmin' || el.field === 'isAdministrator' || el.field === 'isContributor' || el.field === 'isEditor' || el.field === 'archived' || el.content === 'Permissions') { return; } } if (isPageBuilder) { const modulesField = this.props.list.columns .find((item) => item.path === 'modules'); if (modulesField) { let ignoreField = false; const fieldToIgnore = ['modulesRef']; modulesField.field.ops.forEach((op) => { if (op.value === el.field) { ignoreField = true; } }); if (ignoreField || fieldToIgnore.indexOf(el.field) !== -1) { return; } } if (el.field === 'modules') { return React.createElement('div', { className: 'tree-container', key: `modules-${index}` }, this.renderPagesComponent()); } } if (el.type === 'heading') { headings++; el.options.values = this.state.values; el.key = 'h-' + headings; return React.createElement(FormHeading, el); } if (el.type === 'field') { var field = this.props.list.fields[el.field]; var props = this.getFieldProps(field); if (typeof Fields[field.type] !== 'function') { return React.createElement(InvalidFieldType, { type: field.type, path: field.path, key: field.path }); } props.key = field.path; if (props.key === 'state') { if (!this.state.isEditor && !this.state.isAdministrator && this.state.isContributor) { return; } } if (index === 0 && this.state.focusFirstField) { props.autoFocus = true; } return React.createElement(Fields[field.type], props); } }, this); }, renderActionbutton (loading, loadingButtonText, color, state) { return !this.props.list.noedit && (<LoadingButton color={color} disabled={loading} loading={loading} onClick={() => this.updateItem(state)} data-button="update" > {loadingButtonText} </LoadingButton>); }, renderFooterBar () { if (this.props.list.noedit && this.props.list.nodelete) { return null; } const { loading, isPageBuilder, isModelVersion } = this.state; const loadingButtonSaveText = loading ? 'Saving' : 'Save'; const loadingButtonSaveDraftText = loading ? 'Saving Draft' : 'Save Draft'; const loadingButtonPublishText = loading ? 'Publishing' : 'Publish'; const saveButtonColor = isPageBuilder ? 'success' : 'primary'; const loadingButtonSaveAndPublishText = loading ? 'Saving and Publishing' : 'Save and Publish'; // Padding must be applied inline so the FooterBar can determine its // innerHeight at runtime. Aphrodite's styling comes later... return ( <FooterBar style={styles.footerbar}> <div style={styles.footerbarInner}> <Grid.Row> <Grid.Col large="one-third" small="one-third" medium="one-third"> {isPageBuilder && isModelVersion ? this.renderActionbutton(loading, loadingButtonPublishText, 'success', 'published') : isPageBuilder && this.renderActionbutton(loading, loadingButtonSaveDraftText, 'default', 'draft') } </Grid.Col> <Grid.Col large="one-third" small="one-third" medium="one-third"> {isPageBuilder && isModelVersion && this.renderActionbutton(loading, loadingButtonSaveText, 'primary')} {isPageBuilder && !isModelVersion && this.renderActionbutton(loading, loadingButtonSaveAndPublishText, saveButtonColor)} {!isPageBuilder && !isModelVersion && this.renderActionbutton(loading, loadingButtonSaveText, saveButtonColor)} </Grid.Col> <Grid.Col large="one-third" small="one-third" medium="one-third"> {!this.props.list.noedit && ( <Button disabled={loading} onClick={this.toggleResetDialog} variant="link" color="cancel" data-button="reset"> <ResponsiveText hiddenXS="reset changes" visibleXS="reset" /> </Button> )} </Grid.Col> </Grid.Row> </div> </FooterBar> ); }, renderTrackingMeta () { // TODO: These fields are visible now, so we don't want this. We may revisit // it when we have more granular control over hiding fields in certain // contexts, so I'm leaving this code here as a reference for now - JW if (true) return null; // if (true) prevents unreachable code linter errpr if (!this.props.list.tracking) return null; var elements = []; var data = {}; if (this.props.list.tracking.createdAt) { data.createdAt = this.props.data.fields[this.props.list.tracking.createdAt]; if (data.createdAt) { elements.push( <FormField key="createdAt" label="Created on"> <FormInput noedit title={moment(data.createdAt).format('DD/MM/YYYY h:mm:ssa')}>{moment(data.createdAt).format('Do MMM YYYY')}</FormInput> </FormField> ); } } if (this.props.list.tracking.createdBy) { data.createdBy = this.props.data.fields[this.props.list.tracking.createdBy]; if (data.createdBy && data.createdBy.name) { let createdByName = getNameFromData(data.createdBy.name); if (createdByName) { elements.push( <FormField key="createdBy" label="Created by"> <FormInput noedit>{data.createdBy.name.first} {data.createdBy.name.last}</FormInput> </FormField> ); } } } if (this.props.list.tracking.updatedAt) { data.updatedAt = this.props.data.fields[this.props.list.tracking.updatedAt]; if (data.updatedAt && (!data.createdAt || data.createdAt !== data.updatedAt)) { elements.push( <FormField key="updatedAt" label="Updated on"> <FormInput noedit title={moment(data.updatedAt).format('DD/MM/YYYY h:mm:ssa')}>{moment(data.updatedAt).format('Do MMM YYYY')}</FormInput> </FormField> ); } } if (this.props.list.tracking.updatedBy) { data.updatedBy = this.props.data.fields[this.props.list.tracking.updatedBy]; if (data.updatedBy && data.updatedBy.name) { let updatedByName = getNameFromData(data.updatedBy.name); if (updatedByName) { elements.push( <FormField key="updatedBy" label="Updated by"> <FormInput noedit>{data.updatedBy.name.first} {data.updatedBy.name.last}</FormInput> </FormField> ); } } } return Object.keys(elements).length ? ( <div className="EditForm__meta"> <h3 className="form-heading">Meta</h3> {elements} </div> ) : null; }, canDrop ({ node, nextParent, prevPath, nextPath }) { return nextPath.length === 1; }, uuidv4 () { return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) ); }, deleteNode (path) { const treeData2 = removeNodeAtPath({ treeData: this.state.treeData2, path, getNodeKey: ({ treeIndex }) => treeIndex, }); this.setTreeData2(treeData2); }, setTreeData2 (treeData2) { const values = assign({}, this.state.values); const modulesRef = assign({}, this.state.modulesRef); const modulesRefArr = []; const newTreeData2 = treeData2.map((item) => { const newItem = assign({}, item); if (!newItem.id) { newItem.id = this.uuidv4(); } if (newItem.selectedModuleId) { modulesRefArr.push(newItem.selectedModuleId); } return newItem; }); modulesRef.value = modulesRefArr.join('|'); values.modulesRef = modulesRef.value; this.setState({ treeData2: newTreeData2, values, modulesRef }); }, renderRelantionshipField (node) { const fieldId = node.id; const fieldPath = node.path; const field = this.props.list.fields[fieldPath]; field.path = fieldId ? `${fieldPath}_${fieldId}` : fieldPath; field.key = field.path; const props = this.getFieldProps(field); props.many = field.path.indexOf('graphicBannerModules') >= 0 || field.path.indexOf('simpleCardModules') >= 0; props.required = true; return React.createElement(Fields[field.type], props); }, renderPagesComponent () { return ( <div style={styles.modulesContainer}> <div style={styles.headerContainer}> <div style={{ ...styles.headerStyle, ...styles.tree1Style }}><h3>Module Types</h3></div> <div style={{ ...styles.headerStyle, ...styles.tree2Style }}><h3>Page Modules</h3></div> </div> <div className="tree-pages"> <div className="tree-1" style={{ ...styles.treeStyle, ...styles.tree1Style }}> <SortableTree treeData={this.state.treeData1} onChange={treeData1 => this.setState({ treeData1 })} dndType={this.props.list.id} canDrop={() => false} shouldCopyOnOutsideDrop /> </div> <div className="tree-2" style={{ ...styles.treeStyle, ...styles.tree2Style }}> <SortableTree treeData={this.state.treeData2} dndType={this.props.list.id} canDrop={this.canDrop} onChange={this.setTreeData2} generateNodeProps={({ node, path }) => ({ buttons: [ <div key={node}> {this.renderRelantionshipField(node)} </div>, <button key={node} onClick={(e) => { e.preventDefault(); this.deleteNode(path); }} style={this.props.loading ? { color: 'lightgray' } : null} > <span className="octicon octicon-trashcan" /> </button>, ], })} /> </div> </div> </div> ); }, render () { const { isPageBuilder } = this.state; return ( <form ref="editForm" className={`EditForm-container ${isPageBuilder ? 'page-builder' : ''}`}> {(this.state.alerts) ? <AlertMessages alerts={this.state.alerts} /> : null} <Grid.Row> <Grid.Col large="three-quarters"> <Form id="edit-form" layout="horizontal" component="div"> {this.renderNameField()} {this.renderKeyOrId()} {this.renderFormElements()} {this.renderTrackingMeta()} </Form> </Grid.Col> <Grid.Col large="one-quarter"><span /></Grid.Col> </Grid.Row> {this.renderFooterBar()} <ConfirmationDialog confirmationLabel="Reset" isOpen={this.state.resetDialogIsOpen} onCancel={this.toggleResetDialog} onConfirmation={this.handleReset} > <p>Reset your changes to <strong>{this.props.data.name}</strong>?</p> </ConfirmationDialog> <ConfirmationDialog confirmationLabel="Delete" isOpen={this.state.deleteDialogIsOpen} onCancel={this.toggleDeleteDialog} onConfirmation={this.handleDelete} > Are you sure you want to delete <strong>{this.props.data.name}?</strong> <br /> <br /> This cannot be undone. </ConfirmationDialog> {isPageBuilder ? <div> {this.state.treeData2.map((item) => <input key={item.id} id={`${item.path}_${item.id}`} name={item.path} type="hidden" value={item.value} /> )} </div> : null } </form> ); }, }); const styles = { footerbar: { backgroundColor: fade(theme.color.body, 93), boxShadow: '0 -2px 0 rgba(0, 0, 0, 0.1)', paddingBottom: 20, paddingTop: 20, zIndex: 99, }, footerbarInner: { height: theme.component.height, // FIXME aphrodite bug maxWidth: 600, margin: 'auto', }, deleteButton: { float: 'right', }, modulesContainer: { marginTop: 30, marginBottom: 30, minHeight: 400, }, headerContainer: { height: 35, }, headerStyle: { textAlign: 'left', float: 'left', }, treeStyle: { height: 350, float: 'left', border: 'solid #ccc 1px', boxShadow: 'inset 0 1px 1px rgba(0, 0, 0, 0.075)', overflowX: 'auto', }, tree1Style: { width: '34.5%', }, tree2Style: { width: '64.5%', }, }; module.exports = EditForm;