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